From 7667c8c4c5fcbcd13eee90b88158a1f268745498 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Mon, 12 Oct 2026 03:47:07 +0530 Subject: [PATCH 1/9] feat(engine): log the keys each commit changed Add a Changes record to the write-ahead log. It holds the net change to every tree a commit touched, found by diffing the roots before and after the commit: removed keys, and the new value of every added or changed key. Values that hold nested trees (edge members and order, promoted posting lists, index roots) log those trees the same way, against the nested tree of the value they replace. A tree that changed in more than 4,096 places, such as one a bulk load built, is logged as a reference to the pages the commit wrote instead. Commits still write their pages and Root records, and recovery still uses those. Branch creation and closing log no Changes; their BranchMeta record already says what the roots become. --- .../hstore/engine/maintenance/Recovery.java | 2 +- .../io/hstore/engine/tree/Materializer.java | 22 ++- .../java/io/hstore/engine/txn/ChangeLog.java | 160 ++++++++++++++++++ .../hstore/engine/txn/TransactionManager.java | 4 + .../java/io/hstore/engine/wal/WalRecord.java | 6 + 5 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 engine/src/main/java/io/hstore/engine/txn/ChangeLog.java diff --git a/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java b/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java index 7bcef42..b93097e 100644 --- a/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java +++ b/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java @@ -129,7 +129,7 @@ private static Generation redo(Generation base, WalRecord.Commit commit, List feed.append(codec.decode(payload)); - case WalRecord.Begin _, WalRecord.PageRef _, WalRecord.Commit _, WalRecord.Abort _, WalRecord.Checkpoint _ -> { + case WalRecord.Begin _, WalRecord.PageRef _, WalRecord.Changes _, WalRecord.Commit _, WalRecord.Abort _, WalRecord.Checkpoint _ -> { } } } 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 4dfe978..b29cdf4 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Materializer.java +++ b/engine/src/main/java/io/hstore/engine/tree/Materializer.java @@ -7,6 +7,8 @@ import io.hstore.engine.page.PageId; import java.lang.foreign.MemorySegment; +import java.util.IdentityHashMap; +import java.util.Map; public final class Materializer { @@ -19,6 +21,7 @@ public interface Sink { private final Sink sink; private final long epoch; private final int pageSize; + private final Map written = new IdentityHashMap<>(); private long pages; public Materializer(Sink sink, long epoch, int pageSize) { @@ -40,7 +43,17 @@ public Ref materialize(Ref ref, TreeSchema schema) { PageHeader.assign(image, pageId); sink.accept(pageId, image, frozen); pages++; - return new Ref.Stored(pageId, PageId.unitsFor(image.byteSize()), frozen.summary()); + Ref stored = new Ref.Stored(pageId, PageId.unitsFor(image.byteSize()), frozen.summary()); + written.put(node, stored); + return stored; + } + + public Ref writtenAs(Node node) { + Ref ref = written.get(node); + if (ref == null) { + throw new IllegalStateException("node was not written by this materializer"); + } + return ref; } public long pagesWritten() { @@ -92,7 +105,12 @@ private Leaf freeze(Leaf leaf) { } 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); + if (ref instanceof Ref.Pending(Leaf leaf) && Ref.fitsInline(leaf)) { + Ref inline = new Ref.Inline(freeze(leaf)); + written.put(leaf, inline); + return inline; + } + return materialize(ref, schema); } private static Object mapRefs(TreeSchema schema, Object value, ValueCodec.RefMapper mapper) { diff --git a/engine/src/main/java/io/hstore/engine/txn/ChangeLog.java b/engine/src/main/java/io/hstore/engine/txn/ChangeLog.java new file mode 100644 index 0000000..3a05635 --- /dev/null +++ b/engine/src/main/java/io/hstore/engine/txn/ChangeLog.java @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Metacog Labs +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package io.hstore.engine.txn; + +import io.hstore.engine.catalog.RootVector; +import io.hstore.engine.catalog.SlotRegistry; +import io.hstore.engine.page.ByteCursor; +import io.hstore.engine.tree.NodeSource; +import io.hstore.engine.tree.Ref; +import io.hstore.engine.tree.Tree; +import io.hstore.engine.tree.TreeDiff; +import io.hstore.engine.tree.TreeSchema; +import io.hstore.engine.tree.ValueCodec; +import io.hstore.engine.tree.WriteScope; + +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; + +public final class ChangeLog { + + static final int MAX_DELTA = 4096; + + private static final int DELTA = 0; + private static final int ROOT = 1; + private static final int UNCHANGED = 2; + private static final int REMOVE = 0; + private static final int PUT = 1; + + @FunctionalInterface + public interface Durable { + Ref of(Ref.Pending pending, TreeSchema schema); + } + + private record Nested(Ref ref, TreeSchema schema) { + } + + private ChangeLog() { + } + + public static byte[] encode(RootVector before, RootVector after, SlotRegistry slots, NodeSource source, Durable durable) { + TreeSet ids = new TreeSet<>(before.roots().keySet()); + ids.addAll(after.roots().keySet()); + List changed = ids.stream().filter(id -> !Ref.same(before.get(id), after.get(id))).toList(); + ByteCursor out = ByteCursor.growable(256); + out.putVarInt(changed.size()); + for (int id : changed) { + out.putVarInt(id); + writeTree(out, slots.slot(id).schema(), before.get(id), after.get(id), source, durable); + } + return out.toByteArray(); + } + + public static RootVector apply(RootVector roots, byte[] payload, SlotRegistry slots, NodeSource source, WriteScope scope) { + ByteCursor in = ByteCursor.wrap(payload); + RootVector applied = roots; + for (int count = in.getVarInt(); count > 0; count--) { + int id = in.getVarInt(); + applied = applied.with(id, readTree(in, slots.slot(id).schema(), applied.get(id), source, scope)); + } + return applied; + } + + private static void writeTree(ByteCursor out, TreeSchema schema, Ref base, Ref after, NodeSource source, Durable durable) { + if (Ref.same(base, after)) { + out.putByte(UNCHANGED); + return; + } + if (!(after instanceof Ref.Pending pending)) { + out.putByte(ROOT); + Ref.write(out, after); + return; + } + List> deltas = TreeDiff.diff(new Tree<>(schema, source, base), new Tree<>(schema, source, after)) + .limit(MAX_DELTA + 1) + .toList(); + if (deltas.size() > MAX_DELTA) { + out.putByte(ROOT); + Ref.write(out, durable.of(pending, schema)); + return; + } + out.putByte(DELTA).putVarInt(deltas.size()); + for (TreeDiff.Delta delta : deltas) { + out.putLong(delta.key()); + switch (delta) { + case TreeDiff.Delta.Removed _ -> out.putByte(REMOVE); + case TreeDiff.Delta.Added(long key, V value) -> { + out.putByte(PUT); + writeValue(out, schema, key, null, value, source, durable); + } + case TreeDiff.Delta.Changed(long key, V previous, V value) -> { + out.putByte(PUT); + writeValue(out, schema, key, previous, value, source, durable); + } + } + } + } + + private static void writeValue(ByteCursor out, TreeSchema schema, long key, V previous, V value, NodeSource source, Durable durable) { + ValueCodec codec = schema.codec(); + boolean nested = codec.holdsRefs(); + ByteCursor encoded = ByteCursor.growable(64); + codec.encode(encoded, new long[]{key}, List.of(nested ? codec.mapRefs(value, (_, _) -> Ref.EMPTY) : value)); + out.putBlob(encoded.toByteArray()); + if (nested) { + List bases = nested(codec, previous); + int[] position = {0}; + codec.forEachRef(value, (ref, nestedSchema) -> + writeTree(out, nestedSchema, baseAt(bases, position[0]++, nestedSchema), ref, source, durable)); + } + } + + private static Ref readTree(ByteCursor in, TreeSchema schema, Ref base, NodeSource source, WriteScope scope) { + int kind = in.getUnsignedByte(); + return switch (kind) { + case UNCHANGED -> base; + case ROOT -> Ref.read(in, schema); + case DELTA -> { + Tree tree = new Tree<>(schema, source, base); + for (int count = in.getVarInt(); count > 0; count--) { + long key = in.getLong(); + int op = in.getUnsignedByte(); + tree = switch (op) { + case REMOVE -> tree.remove(scope, key); + case PUT -> tree.put(scope, key, readValue(in, schema, key, tree.get(key).orElse(null), source, scope)); + default -> throw new IllegalStateException("unknown change operation " + op); + }; + } + yield tree.root(); + } + default -> throw new IllegalStateException("unknown tree change " + kind); + }; + } + + private static V readValue(ByteCursor in, TreeSchema schema, long key, V previous, NodeSource source, WriteScope scope) { + ValueCodec codec = schema.codec(); + Object[] decoded = new Object[1]; + codec.decode(ByteCursor.wrap(in.getBlob()), new long[]{key}, decoded); + V value = schema.cast(decoded[0]); + if (!codec.holdsRefs()) { + return value; + } + List bases = nested(codec, previous); + int[] position = {0}; + return codec.mapRefs(value, (_, nestedSchema) -> readTree(in, nestedSchema, baseAt(bases, position[0]++, nestedSchema), source, scope)); + } + + private static List nested(ValueCodec codec, V value) { + List refs = new ArrayList<>(2); + if (value != null) { + codec.forEachRef(value, (ref, schema) -> refs.add(new Nested(ref, schema))); + } + return refs; + } + + private static Ref baseAt(List bases, int position, TreeSchema schema) { + return position < bases.size() && bases.get(position).schema().equals(schema) ? bases.get(position).ref() : Ref.EMPTY; + } +} diff --git a/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java b/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java index 10c493a..eeae9b8 100644 --- a/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java +++ b/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java @@ -404,6 +404,10 @@ private Appended append(Generation latest, Branch changed, long txnId, List materializer.writtenAs(pending.node())))); + } records.addAll(meta); long wallTime = System.currentTimeMillis(); CommitEvent event = new CommitEvent(generationId, txnId, wallTime, changed.id(), members, slotChanges); diff --git a/engine/src/main/java/io/hstore/engine/wal/WalRecord.java b/engine/src/main/java/io/hstore/engine/wal/WalRecord.java index 99ba72e..5a1a379 100644 --- a/engine/src/main/java/io/hstore/engine/wal/WalRecord.java +++ b/engine/src/main/java/io/hstore/engine/wal/WalRecord.java @@ -39,6 +39,9 @@ record Abort(long txnId) implements WalRecord { record Checkpoint(long txnId, long generation, long lsn) implements WalRecord { } + record Changes(long txnId, int branch, byte[] payload) implements WalRecord { + } + static int typeOf(WalRecord record) { return switch (record) { case Begin _ -> 1; @@ -50,6 +53,7 @@ static int typeOf(WalRecord record) { case Abort _ -> 7; case Checkpoint _ -> 8; case PageRef _ -> 9; + case Changes _ -> 10; }; } @@ -70,6 +74,7 @@ case Commit(long _, long generation, long wallTime, long nextAtom, long nextTxn) } case Checkpoint(long _, long generation, long lsn) -> out.putVarLong(generation).putVarLong(lsn); case PageRef(long _, long pageId, long address, int length, int checksum) -> out.putLong(pageId).putLong(address).putVarInt(length).putInt(checksum); + case Changes(long _, int branch, byte[] payload) -> out.putVarInt(branch).putBlob(payload); } } @@ -85,6 +90,7 @@ static WalRecord readPayload(int type, long txnId, ByteCursor in) { case 7 -> new Abort(txnId); case 8 -> new Checkpoint(txnId, in.getVarLong(), in.getVarLong()); case 9 -> new PageRef(txnId, in.getLong(), in.getLong(), in.getVarInt(), in.getInt()); + case 10 -> new Changes(txnId, in.getVarInt(), in.getBlob()); default -> throw new IllegalStateException("unknown WAL record type " + type); }; } From f43cc4545d7ae44cbaa9d70749d877fa63f8425d Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Mon, 12 Oct 2026 03:47:07 +0530 Subject: [PATCH 2/9] test(engine): check that replaying each commit's changes rebuilds its trees StorageEngine.verifyChangeLog reads the log, applies every commit's Changes record to the roots of the generation before it and compares the result, entry by entry and through nested trees, with the roots the commit published. Random workloads in the engine and the database layer run it over set and ordered edges, relabels, deletes, bulk and aborted loads, branches, request ids, property writes with validity, property and JSON index backfills, documents and embeddings. --- .../io/hstore/db/ChangeLogReplayTest.java | 171 ++++++++++++++++++ .../java/io/hstore/engine/StorageEngine.java | 6 + .../engine/maintenance/ChangeLogCheck.java | 77 ++++++++ .../java/io/hstore/engine/tree/TreeDiff.java | 52 ++++++ .../java/io/hstore/engine/ChangeLogTest.java | 153 ++++++++++++++++ 5 files changed, 459 insertions(+) create mode 100644 database/src/test/java/io/hstore/db/ChangeLogReplayTest.java create mode 100644 engine/src/main/java/io/hstore/engine/maintenance/ChangeLogCheck.java create mode 100644 engine/src/test/java/io/hstore/engine/ChangeLogTest.java diff --git a/database/src/test/java/io/hstore/db/ChangeLogReplayTest.java b/database/src/test/java/io/hstore/db/ChangeLogReplayTest.java new file mode 100644 index 0000000..e2ef2a3 --- /dev/null +++ b/database/src/test/java/io/hstore/db/ChangeLogReplayTest.java @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Metacog Labs +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package io.hstore.db; + +import io.hstore.db.schema.AtomKind; +import io.hstore.db.schema.TypeDef.PropertyDef; +import io.hstore.db.value.Json; +import io.hstore.db.value.TypeTag; +import io.hstore.db.value.Value; +import io.hstore.engine.EngineOptions; +import io.hstore.engine.catalog.Branch; +import io.hstore.engine.topology.Validity; +import io.hstore.engine.txn.TxnOptions; + +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChangeLogReplayTest { + + @TempDir + Path directory; + + @ParameterizedTest + @ValueSource(longs = {1, 2, 3}) + void replayingTheChangeLogRebuildsPropertiesIndexesAndDocuments(long seed) { + Random random = new Random(seed); + try (HypergraphDatabase database = HypergraphDatabase.open(directory, DatabaseOptions.defaults() + .withEngine(EngineOptions.defaults().withPageSize(4096).withHistoryLimit(100_000).withCheckpointWalBytes(Long.MAX_VALUE)))) { + long first = database.engine().stats().generation(); + database.write(writer -> { + writer.defineNode("Person", List.of(new PropertyDef("name", TypeTag.STRING, true, false), + new PropertyDef("age", TypeTag.INT, false, false))); + writer.defineNode("Paper", List.of(new PropertyDef("title", TypeTag.STRING, false, false))); + writer.defineEdge("Coauthorship", AtomKind.SET_EDGE, List.of(new PropertyDef("year", TypeTag.INT, true, false)), + List.of("lead", "author")); + writer.defineEdge("Route", AtomKind.ORDERED_EDGE, List.of(), List.of()); + return null; + }); + List people = new ArrayList<>(); + List papers = new ArrayList<>(); + List sets = new ArrayList<>(); + List routes = new ArrayList<>(); + int branchChanges = 0; + for (int round = 0; round < 200; round++) { + int choice = random.nextInt(14); + if (round == 80) { + database.write(writer -> writer.createIndex("Person", "age")); + } else if (round == 120) { + database.write(writer -> { + writer.createJsonIndex("Paper", "$.venue.year", TypeTag.INT); + return writer.createJsonIndex("Paper", "$.tags[*]", TypeTag.STRING); + }); + } else if (choice <= 2 || people.size() < 4 || papers.size() < 2) { + int id = round; + database.write(writer -> { + people.add(writer.node("Person", "p" + id, Map.of("name", "person " + id, "age", random.nextInt(90)))); + papers.add(writer.node("Paper", "q" + id, Map.of("title", "paper " + id))); + return null; + }); + } else if (choice == 3) { + long person = pick(random, people); + database.write(writer -> { + writer.set(person, "age", new Value.Int(random.nextInt(90))); + writer.set(person, "name", new Value.Text("renamed " + random.nextInt(1000)), new Validity(random.nextInt(50), 100 + random.nextInt(50))); + return null; + }); + } else if (choice == 4) { + long person = pick(random, people); + database.write(writer -> { + writer.unset(person, "age"); + return null; + }); + } else if (choice == 5) { + long paper = pick(random, papers); + database.write(writer -> { + writer.document(paper, Json.parse("{\"venue\":{\"year\":" + (2000 + random.nextInt(30)) + "},\"tags\":[\"t" + + random.nextInt(5) + "\",\"t" + random.nextInt(5) + "\"]}")); + return null; + }); + } else if (choice == 6) { + database.write(writer -> { + long edge = writer.edge("Coauthorship", Map.of("year", 2000 + random.nextInt(30))); + writer.add(edge, pick(random, people), MemberSpec.roles("lead").withWeight(random.nextInt(4))); + sets.add(edge); + long route = writer.edge("Route"); + writer.append(route, pick(random, people), MemberSpec.PLAIN); + routes.add(route); + return null; + }); + } else if (choice == 7 && !sets.isEmpty()) { + long edge = pick(random, sets); + database.write(writer -> { + for (long person : people.subList(0, Math.min(people.size(), 1 + random.nextInt(40)))) { + if (random.nextBoolean()) { + writer.add(edge, person, MemberSpec.roles("author").withValidity(new Validity(0, 10 + random.nextInt(100)))); + } + } + return null; + }); + } else if (choice == 8 && !routes.isEmpty()) { + long route = pick(random, routes); + int id = round; + database.write(writer -> { + for (int i = 0; i < 40; i++) { + writer.insertAt(route, 0, writer.node("Person", "r" + id + "-" + i, Map.of()), MemberSpec.PLAIN); + } + return null; + }); + } else if (choice == 9 && sets.size() > 2) { + long edge = sets.remove(random.nextInt(sets.size())); + database.write(writer -> { + writer.delete(edge); + return null; + }); + } else if (choice == 10) { + long start = 1_000 + round * 10_000L; + database.write(writer -> { + List members = LongStream.range(0, 30).mapToObj(i -> writer.node("Person", "bulk" + start + "-" + i, Map.of())).toList(); + long edge = writer.edge("Coauthorship", Map.of("year", 1999)); + writer.load(edge, members, MemberSpec.roles("author")); + sets.add(edge); + return null; + }); + } else if (choice == 11) { + Branch branch = database.engine().createBranch("b" + round, Branch.MAIN); + long person = pick(random, people); + database.engine().write(TxnOptions.defaults().onBranch(branch.id()), txn -> { + txn.delete(person); + return null; + }); + branchChanges++; + if (random.nextBoolean()) { + database.engine().dropBranch(branch.id()); + branchChanges++; + } + } else if (choice == 12) { + long paper = pick(random, papers); + database.write(writer -> { + writer.embed(paper, "storage engines and graph queries " + random.nextInt(100)); + return null; + }); + } else { + long person = people.remove(random.nextInt(people.size())); + database.write(writer -> { + writer.delete(person); + return null; + }); + } + } + long commits = database.engine().stats().generation() - first - branchChanges; + assertEquals(commits, database.engine().verifyChangeLog().commits()); + assertTrue(commits > 150); + } + } + + private static long pick(Random random, List atoms) { + return atoms.get(random.nextInt(atoms.size())); + } +} diff --git a/engine/src/main/java/io/hstore/engine/StorageEngine.java b/engine/src/main/java/io/hstore/engine/StorageEngine.java index 35208f1..e057ec3 100644 --- a/engine/src/main/java/io/hstore/engine/StorageEngine.java +++ b/engine/src/main/java/io/hstore/engine/StorageEngine.java @@ -9,6 +9,7 @@ import io.hstore.engine.catalog.SlotRegistry; import io.hstore.engine.feed.ChangeFeed; import io.hstore.engine.feed.FeedCodec; +import io.hstore.engine.maintenance.ChangeLogCheck; import io.hstore.engine.maintenance.Checkpointer; import io.hstore.engine.maintenance.Compactor; import io.hstore.engine.maintenance.Recovery; @@ -308,6 +309,11 @@ public ReferenceCounter.Verification verifyReferences() { return transactions.exclusive(() -> references.verify(transactions.current(), transactions.history())); } + public ChangeLogCheck.Result verifyChangeLog() { + requireOpen(); + return transactions.exclusive(() -> ChangeLogCheck.verify(wal, transactions.history(), slots, transactions.source())); + } + public EngineStats stats() { TransactionManager.Statistics txn = transactions.statistics(); return new EngineStats(transactions.current().id(), txn.commits(), txn.rebases(), txn.conflicts(), diff --git a/engine/src/main/java/io/hstore/engine/maintenance/ChangeLogCheck.java b/engine/src/main/java/io/hstore/engine/maintenance/ChangeLogCheck.java new file mode 100644 index 0000000..b1ebc55 --- /dev/null +++ b/engine/src/main/java/io/hstore/engine/maintenance/ChangeLogCheck.java @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Metacog Labs +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package io.hstore.engine.maintenance; + +import io.hstore.engine.catalog.Generation; +import io.hstore.engine.catalog.RootVector; +import io.hstore.engine.catalog.SlotRegistry; +import io.hstore.engine.tree.NodeSource; +import io.hstore.engine.tree.Tree; +import io.hstore.engine.tree.TreeDiff; +import io.hstore.engine.tree.TreeSchema; +import io.hstore.engine.tree.WriteScope; +import io.hstore.engine.txn.ChangeLog; +import io.hstore.engine.wal.WalRecord; +import io.hstore.engine.wal.WriteAheadLog; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Optional; +import java.util.TreeSet; +import java.util.stream.Stream; + +public final class ChangeLogCheck { + + public record Result(int commits) { + } + + private ChangeLogCheck() { + } + + public static Result verify(WriteAheadLog wal, Collection history, SlotRegistry slots, NodeSource source) { + Map generations = new HashMap<>(); + history.forEach(generation -> generations.put(generation.id(), generation)); + Map open = new HashMap<>(); + int verified = 0; + try (Stream records = wal.read(0)) { + for (Iterator it = records.iterator(); it.hasNext(); ) { + switch (it.next().record()) { + case WalRecord.Changes changes -> open.put(changes.txnId(), changes); + case WalRecord.Abort abort -> open.remove(abort.txnId()); + case WalRecord.Commit commit -> { + WalRecord.Changes changes = open.remove(commit.txnId()); + Generation after = generations.get(commit.generation()); + Generation before = generations.get(commit.generation() - 1); + if (changes != null && after != null && before != null) { + RootVector replayed = ChangeLog.apply(before.branch(changes.branch()).roots(), changes.payload(), slots, source, new WriteScope()); + compare(commit.generation(), slots, source, replayed, after.branch(changes.branch()).roots()); + verified++; + } + } + default -> { + } + } + } + } + return new Result(verified); + } + + private static void compare(long generation, SlotRegistry slots, NodeSource source, RootVector replayed, RootVector expected) { + TreeSet ids = new TreeSet<>(replayed.roots().keySet()); + ids.addAll(expected.roots().keySet()); + for (int id : ids) { + TreeSchema schema = slots.slot(id).schema(); + difference(schema, source, replayed, expected, id).ifPresent(difference -> { + throw new IllegalStateException("generation " + generation + ", slot " + slots.slot(id).name() + + ": replaying the change log gives a different tree, " + difference); + }); + } + } + + private static Optional difference(TreeSchema schema, NodeSource source, RootVector replayed, RootVector expected, int id) { + return TreeDiff.difference(new Tree<>(schema, source, replayed.get(id)), new Tree<>(schema, source, expected.get(id))); + } +} diff --git a/engine/src/main/java/io/hstore/engine/tree/TreeDiff.java b/engine/src/main/java/io/hstore/engine/tree/TreeDiff.java index 5d82a63..44b37e1 100644 --- a/engine/src/main/java/io/hstore/engine/tree/TreeDiff.java +++ b/engine/src/main/java/io/hstore/engine/tree/TreeDiff.java @@ -4,10 +4,13 @@ package io.hstore.engine.tree; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; import java.util.Iterator; +import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; +import java.util.Optional; import java.util.stream.Stream; public final class TreeDiff { @@ -32,6 +35,55 @@ public static Stream> diff(Tree before, Tree after) { return Tree.stream(new Walker<>(before, after)).map(Entry::value); } + public static Optional difference(Tree a, Tree b) { + if (!a.summary().equals(b.summary())) { + return Optional.of("summaries differ: " + a.summary() + " and " + b.summary()); + } + Iterator> left = a.stream().iterator(); + Iterator> right = b.stream().iterator(); + while (left.hasNext() && right.hasNext()) { + Entry x = left.next(); + Entry y = right.next(); + if (x.key() != y.key()) { + return Optional.of("keys differ: " + x.key() + " and " + y.key()); + } + Optional value = valueDifference(a, b, x.value(), y.value()); + if (value.isPresent()) { + return Optional.of("key " + x.key() + ": " + value.get()); + } + } + if (left.hasNext() || right.hasNext()) { + return Optional.of("one tree has more entries"); + } + return Optional.empty(); + } + + private static Optional valueDifference(Tree a, Tree b, V x, V y) { + ValueCodec codec = a.schema().codec(); + if (!codec.holdsRefs()) { + return Objects.equals(x, y) ? Optional.empty() : Optional.of(x + " and " + y); + } + if (!Objects.equals(codec.mapRefs(x, (_, _) -> Ref.EMPTY), codec.mapRefs(y, (_, _) -> Ref.EMPTY))) { + return Optional.of(x + " and " + y); + } + List> nestedX = new ArrayList<>(); + List> nestedY = new ArrayList<>(); + codec.forEachRef(x, (ref, schema) -> nestedX.add(new Tree<>(schema, a.source(), ref))); + codec.forEachRef(y, (ref, schema) -> nestedY.add(new Tree<>(schema, b.source(), ref))); + for (int i = 0; i < nestedX.size(); i++) { + Optional nested = nestedDifference(nestedX.get(i), nestedY.get(i)); + if (nested.isPresent()) { + return Optional.of("nested tree " + i + ", " + nested.get()); + } + } + return Optional.empty(); + } + + @SuppressWarnings("unchecked") + private static Optional nestedDifference(Tree x, Tree y) { + return difference(x, (Tree) y); + } + private sealed interface Item { record Subtree(Ref ref) implements Item { } diff --git a/engine/src/test/java/io/hstore/engine/ChangeLogTest.java b/engine/src/test/java/io/hstore/engine/ChangeLogTest.java new file mode 100644 index 0000000..17e02f8 --- /dev/null +++ b/engine/src/test/java/io/hstore/engine/ChangeLogTest.java @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Metacog Labs +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package io.hstore.engine; + +import io.hstore.engine.catalog.Branch; +import io.hstore.engine.topology.EdgeKind; +import io.hstore.engine.topology.Incidence; +import io.hstore.engine.txn.Transaction; +import io.hstore.engine.txn.TxnOptions; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChangeLogTest { + + @TempDir + Path directory; + + private static EngineOptions options() { + return EngineTest.small().withPagesPerSegment(64).withHistoryLimit(100_000).withCheckpointWalBytes(Long.MAX_VALUE); + } + + @ParameterizedTest + @ValueSource(longs = {1, 2, 3, 4}) + void replayingEachCommitsChangesRebuildsItsTrees(long seed) { + Random random = new Random(seed); + try (StorageEngine engine = StorageEngine.open(directory, options())) { + Map edges = new HashMap<>(); + List nodes = new ArrayList<>(); + long first = engine.stats().generation(); + int branchChanges = 0; + for (int round = 0; round < 160; round++) { + branchChanges += step(engine, random, edges, nodes); + } + long commits = engine.stats().generation() - first - branchChanges; + assertEquals(commits, engine.verifyChangeLog().commits()); + assertTrue(commits > 150); + } + } + + private static int step(StorageEngine engine, Random random, Map edges, List nodes) { + List known = new ArrayList<>(edges.keySet()); + int choice = random.nextInt(12); + if (choice == 0 || known.size() < 3) { + EdgeKind kind = random.nextBoolean() ? EdgeKind.SET : EdgeKind.ORDERED; + edges.put(engine.write(txn -> txn.createEdge(EngineTest.EDGE, kind)), kind); + return 0; + } + long edge = known.get(random.nextInt(known.size())); + EdgeKind kind = edges.get(edge); + switch (choice) { + case 1 -> { + String key = "n" + random.nextInt(1_000_000); + engine.write(txn -> { + nodes.add(txn.createNode(EngineTest.NODE, key)); + return null; + }); + } + case 2 -> { + engine.write(txn -> { + txn.delete(edge); + return null; + }); + edges.remove(edge); + } + case 3 -> { + int size = random.nextBoolean() ? 2_000 : 9_000; + long start = 1_000_000L + random.nextInt(1_000_000); + long target = engine.write(txn -> txn.createEdge(EngineTest.EDGE, EdgeKind.SET)); + edges.put(target, EdgeKind.SET); + engine.write(txn -> { + txn.load(target, LongStream.range(start, start + size).mapToObj(Incidence::of).toList()); + return null; + }); + } + case 4 -> { + try (Transaction abandoned = engine.begin()) { + abandoned.load(edge, LongStream.range(5_000_000, 5_003_000).mapToObj(Incidence::of).toList()); + } + } + case 5 -> { + Branch branch = engine.createBranch("b" + engine.stats().generation(), Branch.MAIN); + for (int i = 0; i < 3; i++) { + long member = 9_000_000L + random.nextInt(1_000_000); + engine.write(TxnOptions.defaults().onBranch(branch.id()), txn -> { + insert(txn, edge, kind, member, 0); + return null; + }); + } + if (random.nextBoolean()) { + engine.dropBranch(branch.id()); + return 2; + } + return 1; + } + case 6 -> engine.write(TxnOptions.defaults().withRequestId("r" + random.nextInt(50)), txn -> { + insert(txn, edge, kind, 7_000_000L + random.nextInt(1_000_000), 0); + return null; + }); + case 7 -> { + if (kind != EdgeKind.ORDERED) { + return 0; + } + engine.write(txn -> { + for (int i = 0; i < 40; i++) { + txn.insertAt(edge, 0, Incidence.of(8_000_000L + random.nextInt(1_000_000))); + } + return null; + }); + } + default -> engine.write(txn -> { + for (int i = 0; i < 1 + random.nextInt(60); i++) { + long member = random.nextInt(20_000); + if (kind == EdgeKind.ORDERED) { + long size = txn.requireEdge(edge).size(); + if (size > 0 && random.nextInt(3) == 0) { + txn.removeAt(edge, random.nextLong(size)); + } else { + txn.insertAt(edge, size == 0 ? 0 : random.nextLong(size + 1), Incidence.of(member).withWeight(random.nextInt(5))); + } + } else if (txn.contains(edge, member)) { + txn.remove(edge, member); + } else { + txn.insert(edge, Incidence.of(member).withWeight(random.nextInt(5))); + } + } + return null; + }); + } + return 0; + } + + private static void insert(Transaction txn, long edge, EdgeKind kind, long member, long index) { + if (kind == EdgeKind.ORDERED) { + txn.insertAt(edge, index, Incidence.of(member)); + } else if (!txn.contains(edge, member)) { + txn.insert(edge, member); + } + } +} From 60fc8716e2ee7e500d808494fd79eb878348272c Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Mon, 12 Oct 2026 04:53:40 +0530 Subject: [PATCH 3/9] feat(engine): write tree pages at checkpoints instead of at commit A commit used to copy the path of every tree it changed into new pages and write them before it returned. One property update wrote about 15 KiB, and every write left dead pages for compaction to move. A commit now logs only its Changes record and publishes roots that point at the new nodes in memory. Pages are written by checkpoints: - A checkpoint takes the published generation, writes the nodes no earlier checkpoint wrote, outside the commit lock, and syncs them. It then counts references from the previous checkpointed generation to this one, publishes the catalog, and swaps the written pages into the head. Only that swap holds the commit lock. The log is kept from the end of that generation's commit, so later commits replay. - Written nodes are stamped with their page and the checkpoint attempt. The next checkpoint reuses those pages instead of writing them again, and Ref.same treats a node as the page it became once its checkpoint published, so a transaction that began before the swap sees no conflict. Two small leaves that would be stored inline compare by content. - Recovery replays Changes records onto the checkpointed roots, and the checkpoint taken at open writes them. History older than the last checkpoint is no longer kept across a restart. - Reference counts change only at checkpoints. Pages a commit writes, spills and trees too large to log key by key, wait as provisional pages until a checkpoint counts them or marks them dead. Pages found without a count at open wait for the first checkpoint the same way. - Memory held by unwritten nodes is limited by writeBufferBytes, 64 MiB by default. The maintenance pass checkpoints at half of it, and a commit that finds it full wakes that pass and waits. - A commit batch syncs the segment files only if one of its commits wrote pages. The page budget counts the nodes a transaction adds. - The maintenance interval is an engine option, so tests can keep the background pass out of their way. Storage format goes to 7. --- .../java/io/hstore/engine/EngineOptions.java | 45 +++-- .../java/io/hstore/engine/StorageEngine.java | 40 ++-- .../engine/maintenance/Checkpointer.java | 92 ++++++--- .../hstore/engine/maintenance/Recovery.java | 22 ++- .../engine/maintenance/ReferenceCounter.java | 83 +++++--- .../java/io/hstore/engine/page/PageStore.java | 18 +- .../java/io/hstore/engine/tree/Epoch.java | 17 ++ .../io/hstore/engine/tree/Materializer.java | 140 ++++++++++++- .../main/java/io/hstore/engine/tree/Node.java | 18 ++ .../main/java/io/hstore/engine/tree/Ref.java | 19 +- .../io/hstore/engine/tree/TreeWalker.java | 20 ++ .../java/io/hstore/engine/txn/CrashPoint.java | 1 + .../io/hstore/engine/txn/GroupCommitter.java | 10 +- .../hstore/engine/txn/ReferenceTracker.java | 5 +- .../hstore/engine/txn/TransactionManager.java | 185 +++++++++++++++--- .../java/io/hstore/engine/CompactionTest.java | 26 ++- .../io/hstore/engine/CrashRecoveryTest.java | 14 +- .../io/hstore/engine/DeferredWritesTest.java | 180 +++++++++++++++++ .../java/io/hstore/engine/EngineTest.java | 15 +- .../io/hstore/engine/InlineMembersTest.java | 4 +- .../io/hstore/engine/ReferenceCountTest.java | 2 + .../java/io/hstore/engine/tree/TreeTest.java | 6 +- .../hstore/engine/txn/GroupCommitterTest.java | 8 +- 23 files changed, 808 insertions(+), 162 deletions(-) create mode 100644 engine/src/main/java/io/hstore/engine/tree/Epoch.java create mode 100644 engine/src/test/java/io/hstore/engine/DeferredWritesTest.java diff --git a/engine/src/main/java/io/hstore/engine/EngineOptions.java b/engine/src/main/java/io/hstore/engine/EngineOptions.java index 1f2512a..02443ce 100644 --- a/engine/src/main/java/io/hstore/engine/EngineOptions.java +++ b/engine/src/main/java/io/hstore/engine/EngineOptions.java @@ -9,6 +9,7 @@ import io.hstore.engine.txn.Durability; import io.hstore.engine.txn.WalMode; +import java.time.Duration; import java.util.List; public record EngineOptions( @@ -20,8 +21,10 @@ public record EngineOptions( WalMode walMode, int historyLimit, long checkpointWalBytes, + long writeBufferBytes, double compactionLiveRatio, long feedRetention, + Duration maintenanceInterval, List> slots, List derivations, CrashPoint.Injector faults) { @@ -36,6 +39,12 @@ public record EngineOptions( if (nodeCacheBytes < 1) { throw new IllegalArgumentException("node cache must be at least one byte: " + nodeCacheBytes); } + if (writeBufferBytes < 1) { + throw new IllegalArgumentException("the write buffer must be at least one byte: " + writeBufferBytes); + } + if (maintenanceInterval.isNegative() || maintenanceInterval.isZero()) { + throw new IllegalArgumentException("the maintenance interval must be positive: " + maintenanceInterval); + } if (feedRetention < 1) { throw new IllegalArgumentException("feed retention must keep at least one generation: " + feedRetention); } @@ -45,66 +54,76 @@ public record EngineOptions( public static EngineOptions defaults() { return new EngineOptions(16 * 1024, 16 * 1024, 64L << 20, 256L << 20, Durability.SYNC, WalMode.PAGE_REFERENCES, 64, - 256L << 20, 0.5, 100_000, List.of(), List.of(), CrashPoint.Injector.NONE); + 256L << 20, 64L << 20, 0.5, 100_000, Duration.ofMillis(500), List.of(), List.of(), CrashPoint.Injector.NONE); } public EngineOptions withPageSize(int bytes) { return new EngineOptions(bytes, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, - checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, feedRetention, maintenanceInterval, slots, derivations, faults); } public EngineOptions withWalSegmentBytes(long bytes) { return new EngineOptions(pageSize, pagesPerSegment, bytes, nodeCacheBytes, durability, walMode, historyLimit, - checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, feedRetention, maintenanceInterval, slots, derivations, faults); } public EngineOptions withPagesPerSegment(int pages) { return new EngineOptions(pageSize, pages, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, - checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, feedRetention, maintenanceInterval, slots, derivations, faults); } public EngineOptions withDurability(Durability mode) { return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, mode, walMode, historyLimit, - checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, feedRetention, maintenanceInterval, slots, derivations, faults); } public EngineOptions withWalMode(WalMode mode) { return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, mode, historyLimit, - checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, feedRetention, maintenanceInterval, slots, derivations, faults); } public EngineOptions withHistoryLimit(int generations) { return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, generations, - checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, feedRetention, maintenanceInterval, slots, derivations, faults); } public EngineOptions withCheckpointWalBytes(long bytes) { return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, - bytes, compactionLiveRatio, feedRetention, slots, derivations, faults); + bytes, writeBufferBytes, compactionLiveRatio, feedRetention, maintenanceInterval, slots, derivations, faults); + } + + public EngineOptions withWriteBufferBytes(long bytes) { + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, + checkpointWalBytes, bytes, compactionLiveRatio, feedRetention, maintenanceInterval, slots, derivations, faults); + } + + public EngineOptions withMaintenanceInterval(Duration interval) { + return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, feedRetention, interval, slots, derivations, faults); } public EngineOptions withNodeCacheBytes(long bytes) { return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, bytes, durability, walMode, historyLimit, - checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, faults); + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, feedRetention, maintenanceInterval, slots, derivations, faults); } public EngineOptions withCompactionLiveRatio(double ratio) { return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, - checkpointWalBytes, ratio, feedRetention, slots, derivations, faults); + checkpointWalBytes, writeBufferBytes, ratio, feedRetention, maintenanceInterval, slots, derivations, faults); } public EngineOptions withFeedRetention(long generations) { return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, - checkpointWalBytes, compactionLiveRatio, generations, slots, derivations, faults); + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, generations, maintenanceInterval, slots, derivations, faults); } public EngineOptions withExtensions(List> extensionSlots, List extensionDerivations) { return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, - checkpointWalBytes, compactionLiveRatio, feedRetention, extensionSlots, extensionDerivations, faults); + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, feedRetention, maintenanceInterval, extensionSlots, extensionDerivations, faults); } public EngineOptions withFaults(CrashPoint.Injector injector) { return new EngineOptions(pageSize, pagesPerSegment, walSegmentBytes, nodeCacheBytes, durability, walMode, historyLimit, - checkpointWalBytes, compactionLiveRatio, feedRetention, slots, derivations, injector); + checkpointWalBytes, writeBufferBytes, compactionLiveRatio, feedRetention, maintenanceInterval, slots, derivations, injector); } } diff --git a/engine/src/main/java/io/hstore/engine/StorageEngine.java b/engine/src/main/java/io/hstore/engine/StorageEngine.java index e057ec3..261d443 100644 --- a/engine/src/main/java/io/hstore/engine/StorageEngine.java +++ b/engine/src/main/java/io/hstore/engine/StorageEngine.java @@ -37,17 +37,17 @@ import java.util.Properties; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.locks.LockSupport; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; public final class StorageEngine implements AutoCloseable { private static final System.Logger LOG = System.getLogger("hstore.engine"); - private static final String FORMAT_VERSION = "6"; + private static final String FORMAT_VERSION = "7"; private static final int CHANGED_COUNTS_LIMIT = 1 << 20; private static final int MAX_ATTEMPTS = 8; - private static final Duration MAINTENANCE_INTERVAL = Duration.ofMillis(500); private final Path directory; private final EngineOptions options; @@ -83,16 +83,17 @@ private StorageEngine(Path directory, EngineOptions options, FileChannel lockCha this.slots = new SlotRegistry(options.slots()); FeedCodec codec = new FeedCodec(slots); this.feed = ChangeFeed.open(directory.resolve("feed"), codec, options.walSegmentBytes()); - this.recovery = Recovery.recover(checkpoint, wal, pages, feed, codec); this.cache = new NodeCache(options.nodeCacheBytes(), options.pageSize()); PagedNodeSource source = new PagedNodeSource(pages, cache); - this.references = ReferenceCounter.recover(pages, source, slots, checkpoint.current(), recovery.replayed(), - recovery.image().history(), recovery.image().current()); + this.recovery = Recovery.recover(checkpoint, wal, pages, feed, codec, slots, source); + this.references = ReferenceCounter.recover(pages, source, slots, checkpoint.current(), checkpoint.history(), + recovery.image().current().id()); TransactionManager.Storage storage = new TransactionManager.Storage(pages, source, wal, feed, codec, slots, - options.derivations(), options.durability(), options.walMode(), options.faults(), options.historyLimit(), references); + options.derivations(), options.durability(), options.walMode(), options.faults(), options.historyLimit(), references, + options.writeBufferBytes()); this.transactions = new TransactionManager(storage, recovery.image().current(), recovery.image().history()); - this.checkpointer = new Checkpointer(transactions, pages, wal, feed, catalog, references, options.faults(), options.historyLimit(), - checkpoint.checkpointLsn()); + this.checkpointer = new Checkpointer(transactions, pages, source, wal, feed, catalog, references, slots, options.faults(), + checkpoint.current(), checkpoint.checkpointLsn()); this.compactor = new Compactor(transactions, pages, checkpointer, options.faults()); this.dictionary = new Dictionary(transactions); checkpointer.checkpoint(); @@ -101,6 +102,7 @@ private StorageEngine(Path directory, EngineOptions options, FileChannel lockCha LOG.log(System.Logger.Level.INFO, "database at {0} is ready: generation {1}, {2} data segments, page size {3}, {4} durability, {5} wal", directory, transactions.current().id(), pages.segments().size(), options.pageSize(), options.durability(), options.walMode()); this.maintainer = Thread.ofVirtual().name("hstore-maintenance").start(this::maintain); + transactions.whenFull(() -> LockSupport.unpark(maintainer)); } public static StorageEngine open(Path directory, EngineOptions options) { @@ -133,7 +135,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 + " (inline member trees); export and reload it"); + + "; this build reads format " + FORMAT_VERSION + " (commits logged as key changes); export and reload it"); } int stored = Integer.parseInt(format.getProperty("page-size")); if (stored != options.pageSize()) { @@ -306,7 +308,13 @@ public Map liveness() { public ReferenceCounter.Verification verifyReferences() { requireOpen(); - return transactions.exclusive(() -> references.verify(transactions.current(), transactions.history())); + maintenance.lock(); + try { + timedCheckpoint(); + return transactions.exclusive(() -> references.verify(checkpointer.materialized(), transactions.history())); + } finally { + maintenance.unlock(); + } } public ChangeLogCheck.Result verifyChangeLog() { @@ -324,12 +332,16 @@ public EngineStats stats() { private void maintain() { while (!closed) { try { - Thread.sleep(MAINTENANCE_INTERVAL); + LockSupport.parkNanos(options.maintenanceInterval().toNanos()); + if (Thread.interrupted()) { + return; + } if (closed || !maintenance.tryLock()) { continue; } try { - if (checkpointer.walSinceCheckpoint() > options.checkpointWalBytes() + if (transactions.recountUnwritten() > options.writeBufferBytes() / 2 + || checkpointer.walSinceCheckpoint() > options.checkpointWalBytes() || pages.bytesWritten() - writtenAtCheckpoint > options.checkpointWalBytes() || references.pending() > CHANGED_COUNTS_LIMIT) { timedCheckpoint(); @@ -348,8 +360,6 @@ private void maintain() { } finally { maintenance.unlock(); } - } catch (InterruptedException e) { - return; } catch (RuntimeException e) { if (!closed) { System.getLogger("hstore").log(System.Logger.Level.WARNING, "background maintenance failed", e); @@ -391,7 +401,7 @@ private void shutdown() { references.close(); maintainer.interrupt(); try { - maintainer.join(MAINTENANCE_INTERVAL.multipliedBy(4)); + maintainer.join(Duration.ofSeconds(2)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } diff --git a/engine/src/main/java/io/hstore/engine/maintenance/Checkpointer.java b/engine/src/main/java/io/hstore/engine/maintenance/Checkpointer.java index ddd00e0..dfddb11 100644 --- a/engine/src/main/java/io/hstore/engine/maintenance/Checkpointer.java +++ b/engine/src/main/java/io/hstore/engine/maintenance/Checkpointer.java @@ -3,17 +3,27 @@ package io.hstore.engine.maintenance; +import io.hstore.engine.catalog.Branch; import io.hstore.engine.catalog.CatalogImage; import io.hstore.engine.catalog.CatalogStore; import io.hstore.engine.catalog.Generation; +import io.hstore.engine.catalog.SlotRegistry; import io.hstore.engine.feed.ChangeFeed; import io.hstore.engine.page.PageStore; +import io.hstore.engine.tree.Epoch; +import io.hstore.engine.tree.Materializer; +import io.hstore.engine.tree.Node; +import io.hstore.engine.tree.PagedNodeSource; import io.hstore.engine.txn.CrashPoint; import io.hstore.engine.txn.TransactionManager; import io.hstore.engine.wal.WalRecord; import io.hstore.engine.wal.WriteAheadLog; +import java.lang.foreign.MemorySegment; import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.LongStream; public final class Checkpointer { @@ -22,27 +32,31 @@ public record Result(long generation, long lsn, int walSegments) { private final TransactionManager transactions; private final PageStore pages; + private final PagedNodeSource source; private final WriteAheadLog wal; private final ChangeFeed feed; private final CatalogStore catalog; private final ReferenceCounter references; + private final SlotRegistry slots; private final CrashPoint.Injector faults; - private final int historyLimit; private volatile long lastLsn; private volatile long completed; private long unapplied = -1; + private Generation materialized; - public Checkpointer(TransactionManager transactions, PageStore pages, WriteAheadLog wal, ChangeFeed feed, - CatalogStore catalog, ReferenceCounter references, CrashPoint.Injector faults, int historyLimit, - long recoveredLsn) { + public Checkpointer(TransactionManager transactions, PageStore pages, PagedNodeSource source, WriteAheadLog wal, ChangeFeed feed, + CatalogStore catalog, ReferenceCounter references, SlotRegistry slots, CrashPoint.Injector faults, + Generation checkpointed, long recoveredLsn) { this.transactions = transactions; this.pages = pages; + this.source = source; this.wal = wal; this.feed = feed; this.catalog = catalog; this.references = references; + this.slots = slots; this.faults = faults; - this.historyLimit = historyLimit; + this.materialized = checkpointed; this.lastLsn = recoveredLsn; } @@ -54,18 +68,26 @@ public long completed() { return completed; } + public Generation materialized() { + return materialized; + } + public Result checkpoint() { - pages.checkpoint(); - Result result = transactions.exclusive(() -> { - references.pause(); - try { - return publish(); - } catch (RuntimeException | Error failure) { - references.resume(); - throw failure; - } - }); + Generation target = transactions.current(); + Epoch epoch = new Epoch(); + LongStream.Builder written = LongStream.builder(); + Generation stored; try { + stored = materialize(target, epoch, written); + faults.reach(CrashPoint.CHECKPOINT_PAGES); + pages.checkpoint(); + } catch (RuntimeException | Error failure) { + references.discarded(written.build().toArray()); + throw failure; + } + references.pause(); + try { + Result result = publish(target, stored, epoch); faults.reach(CrashPoint.CHECKPOINT_JOURNAL); pages.applyJournal(); unapplied = -1; @@ -77,33 +99,51 @@ public Result checkpoint() { } } - private Result publish() { + private Generation materialize(Generation target, Epoch epoch, LongStream.Builder written) { + Materializer materializer = new Materializer(new Materializer.Sink() { + @Override + public long allocate(int length) { + long pageId = pages.allocate(length); + written.add(pageId); + return pageId; + } + + @Override + public void accept(long pageId, MemorySegment image, Node frozen) { + pages.write(pageId, image); + source.admit(pageId, frozen); + } + }, target.id(), pages.pageSize(), epoch, transactions.publishedEpoch()); + Map branches = new TreeMap<>(); + target.branches().forEach((id, branch) -> branches.put(id, branch.withRoots(branch.roots().map((slot, ref) -> + materializer.materialize(ref, slots.slot(slot).schema()))))); + return new Generation(target.id(), target.wallTime(), target.txnId(), target.nextAtom(), target.nextTxn(), branches); + } + + private Result publish(Generation target, Generation stored, Epoch epoch) { if (unapplied >= 0) { pages.settleJournal(unapplied); unapplied = -1; } - long lsn = wal.end(); + long lsn = target.id() == materialized.id() ? lastLsn : transactions.commitEnd(target.id()); + references.checkpointed(materialized, stored); + materialized = stored; references.release(transactions.oldestReadable()); pages.checkpoint(); pages.journal(lsn); feed.sync(); - Generation current = transactions.current(); - List history = transactions.history().stream() - .filter(generation -> generation.id() != current.id()) - .sorted((a, b) -> Long.compare(b.id(), a.id())) - .limit(historyLimit) - .toList(); faults.reach(CrashPoint.CHECKPOINT_CATALOG); try { - catalog.publish(new CatalogImage(current, history, pages.segments(), lsn, feed.size())); + catalog.publish(new CatalogImage(stored, List.of(), pages.segments(), lsn, feed.size())); } catch (RuntimeException | Error failure) { pages.references().distrust(); throw failure; } unapplied = lsn; - wal.append(new WalRecord.Checkpoint(0, current.id(), lsn)); + transactions.checkpointed(stored, epoch); + wal.append(new WalRecord.Checkpoint(0, stored.id(), lsn)); wal.sync(); wal.truncateBefore(lsn); - return new Result(current.id(), lsn, wal.segmentCount()); + return new Result(stored.id(), lsn, wal.segmentCount()); } } diff --git a/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java b/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java index b93097e..70aab5f 100644 --- a/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java +++ b/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java @@ -8,11 +8,15 @@ import io.hstore.engine.catalog.CatalogStore; import io.hstore.engine.catalog.Generation; import io.hstore.engine.catalog.RootVector; +import io.hstore.engine.catalog.SlotRegistry; import io.hstore.engine.feed.ChangeFeed; import io.hstore.engine.feed.FeedCodec; import io.hstore.engine.page.Checksums; import io.hstore.engine.page.PageHeader; import io.hstore.engine.page.PageStore; +import io.hstore.engine.tree.NodeSource; +import io.hstore.engine.tree.WriteScope; +import io.hstore.engine.txn.ChangeLog; import io.hstore.engine.wal.WalRecord; import io.hstore.engine.wal.WriteAheadLog; @@ -42,7 +46,8 @@ public static CatalogImage discover(CatalogStore catalog) { return catalog.loadLatest().orElseGet(CatalogImage::initial); } - public static Outcome recover(CatalogImage checkpoint, WriteAheadLog wal, PageStore pages, ChangeFeed feed, FeedCodec codec) { + public static Outcome recover(CatalogImage checkpoint, WriteAheadLog wal, PageStore pages, ChangeFeed feed, FeedCodec codec, + SlotRegistry slots, NodeSource source) { Map> pending = new HashMap<>(); List commits = new ArrayList<>(); wal.read(checkpoint.checkpointLsn()).forEach(positioned -> { @@ -73,7 +78,7 @@ public static Outcome recover(CatalogImage checkpoint, WriteAheadLog wal, PageSt discarded++; continue; } - generation = redo(generation, commit, body, pages, feed, codec); + generation = redo(generation, commit, body, pages, feed, codec, slots, source); history.add(generation); replayed.add(generation); } @@ -113,7 +118,7 @@ private static boolean intact(List records, PageStore pages) { } private static Generation redo(Generation base, WalRecord.Commit commit, List records, - PageStore pages, ChangeFeed feed, FeedCodec codec) { + PageStore pages, ChangeFeed feed, FeedCodec codec, SlotRegistry slots, NodeSource source) { Map branches = new TreeMap<>(base.branches()); List metaFirst = Stream.concat(records.stream().filter(WalRecord.BranchMeta.class::isInstance), records.stream().filter(record -> !(record instanceof WalRecord.BranchMeta))).toList(); @@ -121,15 +126,22 @@ private static Generation redo(Generation base, WalRecord.Commit commit, List pages.write(pageId, MemorySegment.ofArray(image)); case WalRecord.BranchMeta(long _, int id, String name, int parent, long baseGeneration, long created, Branch.State state) -> { - RootVector roots = branches.containsKey(id) ? branches.get(id).roots() : RootVector.EMPTY; + RootVector roots = state != Branch.State.ACTIVE ? RootVector.EMPTY + : branches.containsKey(id) ? branches.get(id).roots() : branches.get(parent).roots(); branches.put(id, new Branch(id, name, parent, baseGeneration, created, state, roots)); } + case WalRecord.Changes(long _, int branch, byte[] payload) -> { + Branch target = branches.get(branch); + WriteScope scope = new WriteScope(); + branches.put(branch, target.withRoots(ChangeLog.apply(target.roots(), payload, slots, source, scope))); + scope.freeze(); + } case WalRecord.Root(long _, int branch, int slot, var root) -> { Branch target = branches.get(branch); branches.put(branch, target.withRoots(target.roots().with(slot, root))); } case WalRecord.Feed(long _, byte[] payload) -> feed.append(codec.decode(payload)); - case WalRecord.Begin _, WalRecord.PageRef _, WalRecord.Changes _, WalRecord.Commit _, WalRecord.Abort _, WalRecord.Checkpoint _ -> { + case WalRecord.Begin _, WalRecord.PageRef _, WalRecord.Commit _, WalRecord.Abort _, WalRecord.Checkpoint _ -> { } } } diff --git a/engine/src/main/java/io/hstore/engine/maintenance/ReferenceCounter.java b/engine/src/main/java/io/hstore/engine/maintenance/ReferenceCounter.java index f8590a6..330757d 100644 --- a/engine/src/main/java/io/hstore/engine/maintenance/ReferenceCounter.java +++ b/engine/src/main/java/io/hstore/engine/maintenance/ReferenceCounter.java @@ -24,12 +24,15 @@ import java.util.Arrays; import java.util.Collection; import java.util.Comparator; +import java.util.Iterator; import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.Semaphore; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Supplier; import java.util.stream.IntStream; +import java.util.stream.LongStream; import static io.hstore.engine.tree.TreeWalker.UNKNOWN_HEIGHT; @@ -41,21 +44,21 @@ public record Verification(long livePages, long waitingPages) { private sealed interface Change { } - private record Committed(Generation before, Generation after, long[] spilled) implements Change { + private record Discarded(long[] spilled) implements Change { } - private record Discarded(long[] spilled) implements Change { + private record Provisional(long generation, long[] pages) { } private static final System.Logger LOG = System.getLogger("hstore.references"); private static final int QUEUE_LIMIT = 4096; - private static final long[] NO_PAGES = new long[0]; private final PageStore pages; private final ReferenceCounts counts; private final TreeWalker walker; private final SlotRegistry slots; private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(QUEUE_LIMIT); + private final ConcurrentLinkedQueue provisional = new ConcurrentLinkedQueue<>(); private final Semaphore ready = new Semaphore(0); private final ReentrantLock processing = new ReentrantLock(); private final LongLongMap deltas = new LongLongMap(1 << 10); @@ -79,34 +82,24 @@ private ReferenceCounter(PageStore pages, PagedNodeSource source, SlotRegistry s } public static ReferenceCounter recover(PageStore pages, PagedNodeSource source, SlotRegistry slots, Generation checkpointed, - List replayed, Collection retained, Generation current) { - ReferenceCounter counter = new ReferenceCounter(pages, source, slots, current.id()); - counter.catchUp(checkpointed, replayed, retained, current); + Collection retained, long head) { + ReferenceCounter counter = new ReferenceCounter(pages, source, slots, checkpointed.id()); + counter.catchUp(checkpointed, retained, head); return counter; } - private void catchUp(Generation checkpointed, List replayed, Collection retained, Generation current) { - if (counts.trusted()) { - try { - Generation before = checkpointed; - for (Generation after : replayed) { - account(before, after, NO_PAGES); - before = after; - } - } catch (RuntimeException failure) { - LOG.log(System.Logger.Level.WARNING, "reference counts could not follow the log, rebuilding them", failure); - counts.distrust(); - } - } + private void catchUp(Generation checkpointed, Collection retained, long head) { if (!counts.trusted()) { long started = System.nanoTime(); - rebuild(retained, current); + rebuild(retained, checkpointed); LOG.log(System.Logger.Level.INFO, "rebuilt reference counts in {0} ms", (System.nanoTime() - started) / 1_000_000); } - PageStore.Scan scan = pages.scan(true); + PageStore.Scan scan = pages.scan(); limbo.restore(scan.dead()); - if (scan.orphans() > 0) { - LOG.log(System.Logger.Level.INFO, "released {0} pages that no committed generation refers to", scan.orphans()); + if (scan.unreferenced().length > 0) { + provisional.add(new Provisional(head, scan.unreferenced())); + LOG.log(System.Logger.Level.INFO, "{0} pages are not counted yet; the first checkpoint counts them or releases them", + scan.unreferenced().length); } if (scan.unplaced() > 0) { LOG.log(System.Logger.Level.WARNING, "{0} referenced pages have no address", scan.unplaced()); @@ -118,8 +111,42 @@ public void start() { } @Override - public void committed(Generation before, Generation after, long[] spilled) { - enqueue(new Committed(before, after, spilled)); + public void provisional(long generation, long[] pages) { + if (pages.length > 0) { + provisional.add(new Provisional(generation, pages)); + } + } + + public void checkpointed(Generation before, Generation after) { + if (!processing.isHeldByCurrentThread()) { + throw new IllegalStateException("reference counts change at a checkpoint only while counting is paused"); + } + if (broken != null) { + return; + } + try { + account(before, after, provisionalThrough(after.id())); + } catch (RuntimeException | Error failure) { + broken = failure; + LOG.log(System.Logger.Level.ERROR, "reference counting stopped; the counts will be rebuilt when the database next opens", failure); + counts.distrust(); + } + } + + private long[] provisionalThrough(long generation) { + LongStream.Builder pages = LongStream.builder(); + for (Iterator entries = provisional.iterator(); entries.hasNext(); ) { + Provisional entry = entries.next(); + if (entry.generation() <= generation) { + Arrays.stream(entry.pages()).forEach(pages::add); + entries.remove(); + } + } + return pages.build().toArray(); + } + + public boolean isProvisional(long pageId) { + return provisional.stream().anyMatch(entry -> Arrays.stream(entry.pages()).anyMatch(page -> page == pageId)); } @Override @@ -170,7 +197,6 @@ private void drain() { } try { switch (change) { - case Committed(Generation before, Generation after, long[] spilled) -> account(before, after, spilled); case Discarded(long[] spilled) -> orphaned(spilled, generation); } } catch (RuntimeException | Error failure) { @@ -432,7 +458,7 @@ public Verification verify(Generation current, Collection retained) return; } long entry = counts.entry(index); - if (entry == 0) { + if (entry == 0 && !isProvisional(pageId)) { problems.add("page #" + index + " is still readable from generation " + reader[0] + " but is not counted"); } else if (ReferenceCounts.dead(entry) && ReferenceCounts.deadAt(entry) <= reader[0]) { problems.add("page #" + index + " is readable from generation " + reader[0] + " but counted " + describe(entry)); @@ -444,7 +470,8 @@ public Verification verify(Generation current, Collection retained) }; for (Generation generation : retained.stream().sorted(Comparator.comparingLong(Generation::id)).toList()) { reader[0] = generation.id(); - forEachRoot(generation, readable); + generation.branches().values().forEach(branch -> branch.roots().roots().forEach((slot, ref) -> + TreeWalker.forEachStored(ref, slots.slot(slot).schema(), readable))); while (!pending.isEmpty()) { pending.pop(); walker.references(pending.page, pending.schema, pending.height, readable); diff --git a/engine/src/main/java/io/hstore/engine/page/PageStore.java b/engine/src/main/java/io/hstore/engine/page/PageStore.java index 8caecc0..b55a8df 100644 --- a/engine/src/main/java/io/hstore/engine/page/PageStore.java +++ b/engine/src/main/java/io/hstore/engine/page/PageStore.java @@ -24,7 +24,7 @@ public final class PageStore implements AutoCloseable { - public record Scan(long[] dead, long orphans, long unplaced) { + public record Scan(long[] dead, long[] unreferenced, long unplaced) { } private static final long PASS_BYTES = 4L << 20; @@ -356,10 +356,11 @@ public void release(long pageId) { released[releasedCount++] = index; } - public Scan scan(boolean clearOrphans) { + public Scan scan() { long[] dead = new long[64]; int deadCount = 0; - long orphans = 0; + long[] unreferenced = new long[64]; + int unreferencedCount = 0; long unplaced = 0; allocation.lock(); try { @@ -370,11 +371,14 @@ public Scan scan(boolean clearOrphans) { long entry = references.entry(index); if (address == PageAddress.NONE) { unplaced += entry > 0 ? 1 : 0; - } else if (entry == 0 && clearOrphans) { - addresses.put(index, PageAddress.NONE); - orphans++; } else { adjustLive(address, 1); + if (entry == 0) { + if (unreferencedCount == unreferenced.length) { + unreferenced = Arrays.copyOf(unreferenced, unreferenced.length * 2); + } + unreferenced[unreferencedCount++] = PageId.pack(index, 0); + } } if (ReferenceCounts.dead(entry)) { if (deadCount + 2 > dead.length) { @@ -387,7 +391,7 @@ public Scan scan(boolean clearOrphans) { } finally { allocation.unlock(); } - return new Scan(Arrays.copyOf(dead, deadCount), orphans, unplaced); + return new Scan(Arrays.copyOf(dead, deadCount), Arrays.copyOf(unreferenced, unreferencedCount), unplaced); } private long locate(long pageId) { diff --git a/engine/src/main/java/io/hstore/engine/tree/Epoch.java b/engine/src/main/java/io/hstore/engine/tree/Epoch.java new file mode 100644 index 0000000..8258d18 --- /dev/null +++ b/engine/src/main/java/io/hstore/engine/tree/Epoch.java @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Metacog Labs +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package io.hstore.engine.tree; + +public final class Epoch { + + private volatile boolean published; + + public void publish() { + published = true; + } + + public boolean published() { + return published; + } +} 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 b29cdf4..f6f5e26 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Materializer.java +++ b/engine/src/main/java/io/hstore/engine/tree/Materializer.java @@ -7,8 +7,7 @@ import io.hstore.engine.page.PageId; import java.lang.foreign.MemorySegment; -import java.util.IdentityHashMap; -import java.util.Map; +import java.util.function.Consumer; public final class Materializer { @@ -18,22 +17,36 @@ public interface Sink { void accept(long pageId, MemorySegment image, Node frozen); } + public record Unwritten(long nodes, long heapBytes) { + } + private final Sink sink; private final long epoch; private final int pageSize; - private final Map written = new IdentityHashMap<>(); + private final Epoch stamping; + private final Epoch reusable; private long pages; public Materializer(Sink sink, long epoch, int pageSize) { + this(sink, epoch, pageSize, null, null); + } + + public Materializer(Sink sink, long epoch, int pageSize, Epoch stamping, Epoch reusable) { this.sink = sink; this.epoch = epoch; this.pageSize = pageSize; + this.stamping = stamping; + this.reusable = reusable; } public Ref materialize(Ref ref, TreeSchema schema) { if (!(ref instanceof Ref.Pending(Node node))) { return ref; } + Ref reused = reused(node); + if (reused != null) { + return reused; + } Node frozen = switch (node) { case Leaf leaf -> freeze(leaf); case Branch branch -> freeze(branch); @@ -44,16 +57,21 @@ public Ref materialize(Ref ref, TreeSchema schema) { sink.accept(pageId, image, frozen); pages++; Ref stored = new Ref.Stored(pageId, PageId.unitsFor(image.byteSize()), frozen.summary()); - written.put(node, stored); + if (stamping != null) { + node.stamp(stored, stamping); + } return stored; } - public Ref writtenAs(Node node) { - Ref ref = written.get(node); - if (ref == null) { - throw new IllegalStateException("node was not written by this materializer"); + private Ref reused(Node node) { + if (reusable == null) { + return null; + } + Ref written = node.writtenIn(reusable); + if (written != null && stamping != null) { + node.stamp(written, stamping); } - return ref; + return written; } public long pagesWritten() { @@ -106,8 +124,14 @@ private Leaf freeze(Leaf leaf) { private Ref inlineOrMaterialize(Ref ref, TreeSchema schema) { if (ref instanceof Ref.Pending(Leaf leaf) && Ref.fitsInline(leaf)) { + Ref reusedInline = reused(leaf); + if (reusedInline != null) { + return reusedInline; + } Ref inline = new Ref.Inline(freeze(leaf)); - written.put(leaf, inline); + if (stamping != null) { + leaf.stamp(inline, stamping); + } return inline; } return materialize(ref, schema); @@ -124,4 +148,100 @@ private Branch freeze(Branch branch) { } return branch.frozenWith(children); } + public static Unwritten unwritten(Ref ref, TreeSchema schema, WriteScope scope) { + long[] totals = new long[2]; + countOwned(ref, scope, totals); + return new Unwritten(totals[0], totals[1]); + } + + private static void countOwned(Ref ref, WriteScope scope, long[] totals) { + if (!(ref instanceof Ref.Pending(Node node)) || !node.writableBy(scope)) { + return; + } + totals[0]++; + totals[1] += node.heapBytes(); + switch (node) { + case Leaf leaf -> { + if (leaf.schema.codec().holdsRefs()) { + boolean inlines = leaf.schema.codec().inlinesSmallTrees(); + leaf.valueList().forEach(value -> forEachNested(leaf.schema, value, nested -> { + if (!(inlines && staysInline(nested))) { + countOwned(nested, scope, totals); + } + })); + } + } + case Branch branch -> { + for (Ref child : branch.childArray()) { + countOwned(child, scope, totals); + } + } + } + } + + private static void forEachNested(TreeSchema schema, Object value, Consumer action) { + schema.codec().forEachRef(schema.cast(value), (nested, _) -> action.accept(nested)); + } + + public static long unwrittenHeap(Ref ref) { + if (!(ref instanceof Ref.Pending(Node node)) || node.published() != null) { + return 0; + } + long[] total = {node.heapBytes()}; + switch (node) { + case Leaf leaf -> { + if (leaf.schema.codec().holdsRefs()) { + leaf.valueList().forEach(value -> forEachNested(leaf.schema, value, nested -> total[0] += unwrittenHeap(nested))); + } + } + case Branch branch -> { + for (Ref child : branch.childArray()) { + total[0] += unwrittenHeap(child); + } + } + } + return total[0]; + } + + public static Ref rebound(Ref ref, TreeSchema schema, Epoch epoch) { + if (!(ref instanceof Ref.Pending(Node node))) { + return ref; + } + Ref written = node.writtenIn(epoch); + if (written != null) { + return written; + } + return switch (node) { + case Leaf leaf -> { + if (!leaf.schema.codec().holdsRefs()) { + yield ref; + } + Object[] values = leaf.valueList().toArray(); + boolean changed = false; + for (int i = 0; i < values.length; i++) { + boolean[] moved = {false}; + Object mapped = mapRefs(leaf.schema, values[i], (nested, nestedSchema) -> { + Ref rebound = rebound(nested, nestedSchema, epoch); + moved[0] |= rebound != nested; + return rebound; + }); + if (moved[0]) { + values[i] = mapped; + changed = true; + } + } + yield changed ? new Ref.Pending(leaf.frozenWith(values)) : ref; + } + case Branch branch -> { + Ref[] children = branch.childArray(); + boolean changed = false; + for (int i = 0; i < children.length; i++) { + Ref rebound = rebound(children[i], branch.schema, epoch); + changed |= rebound != children[i]; + children[i] = rebound; + } + yield changed ? new Ref.Pending(branch.frozenWith(children)) : ref; + } + }; + } } diff --git a/engine/src/main/java/io/hstore/engine/tree/Node.java b/engine/src/main/java/io/hstore/engine/tree/Node.java index 6b7c0cd..0416ca3 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Node.java +++ b/engine/src/main/java/io/hstore/engine/tree/Node.java @@ -8,9 +8,13 @@ public sealed abstract class Node permits Leaf, Branch { static final int NODE_HEAP_BYTES = 144; static final int ARRAY_HEAP_BYTES = 16; + private record Stamp(Ref ref, Epoch epoch) { + } + final TreeSchema schema; final Object owner; private volatile Summary summary; + private volatile Stamp stamp; Node(TreeSchema schema, Object owner, Summary summary) { this.schema = schema; @@ -35,6 +39,20 @@ final void invalidate() { summary = null; } + final void stamp(Ref written, Epoch epoch) { + stamp = new Stamp(written, epoch); + } + + final Ref writtenIn(Epoch epoch) { + Stamp current = stamp; + return current != null && current.epoch() == epoch ? current.ref() : null; + } + + final Ref published() { + Stamp current = stamp; + return current != null && current.epoch().published() ? current.ref() : null; + } + final boolean writableBy(WriteScope scope) { return owner != null && owner == scope.token(); } 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 a2751bd..3e0774d 100644 --- a/engine/src/main/java/io/hstore/engine/tree/Ref.java +++ b/engine/src/main/java/io/hstore/engine/tree/Ref.java @@ -97,12 +97,25 @@ public String toString() { 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); + case Stored(long page, _, _) -> b instanceof Stored(long other, _, _) ? page == other : b instanceof Pending(Node node) && sameWritten(node, a); + case Pending(Node node) -> b instanceof Pending(Node other) + ? node == other || sameWritten(node, b) || sameSmallLeaf(node, other) + : sameWritten(node, b) || b instanceof Inline(Leaf leaf) && sameSmallLeaf(node, leaf); + case Inline inline -> b instanceof Pending(Node other) ? sameWritten(other, a) || sameSmallLeaf(other, inline.leaf()) : inline.equals(b); }; } + private static boolean sameSmallLeaf(Node a, Node b) { + return a instanceof Leaf left && b instanceof Leaf right && fitsInline(left) && fitsInline(right) && left.sameEntries(right); + } + + private static boolean sameWritten(Node node, Ref other) { + Ref written = node.published(); + return written != null && !(written instanceof Pending) && (other instanceof Pending(Node that) + ? that.published() != null && same(written, that.published()) + : same(written, other)); + } + static boolean fitsInline(Leaf leaf) { return leaf.size() > 0 && leaf.size() <= INLINE_MAX_ENTRIES && !leaf.schema.codec().holdsRefs() && inlineSize(leaf) <= maxEncodedSize(); 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 b8bc6a9..43a8179 100644 --- a/engine/src/main/java/io/hstore/engine/tree/TreeWalker.java +++ b/engine/src/main/java/io/hstore/engine/tree/TreeWalker.java @@ -49,6 +49,26 @@ public void references(long pageId, TreeSchema schema, int height, Reference } } + public static void forEachStored(Ref ref, TreeSchema schema, Reference into) { + switch (ref) { + case Ref.Stored(long pageId, int _, Summary _) -> into.accept(pageId, schema, UNKNOWN_HEIGHT); + case Ref.Pending(Leaf leaf) -> { + if (schema.codec().holdsRefs()) { + for (int i = 0; i < leaf.size(); i++) { + nested(schema, leaf.value(i), (nested, nestedSchema) -> forEachStored(nested, nestedSchema, into)); + } + } + } + case Ref.Pending(Branch branch) -> { + for (int i = 0; i < branch.size(); i++) { + forEachStored(branch.child(i), schema, into); + } + } + default -> { + } + } + } + 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/txn/CrashPoint.java b/engine/src/main/java/io/hstore/engine/txn/CrashPoint.java index dca1088..caa6c57 100644 --- a/engine/src/main/java/io/hstore/engine/txn/CrashPoint.java +++ b/engine/src/main/java/io/hstore/engine/txn/CrashPoint.java @@ -13,6 +13,7 @@ public enum CrashPoint { CATALOG_PUBLISH, COMPACTION_MOVE, COMPACTION_RETIRE, + CHECKPOINT_PAGES, CHECKPOINT_CATALOG, CHECKPOINT_JOURNAL; diff --git a/engine/src/main/java/io/hstore/engine/txn/GroupCommitter.java b/engine/src/main/java/io/hstore/engine/txn/GroupCommitter.java index 6f929f2..b177470 100644 --- a/engine/src/main/java/io/hstore/engine/txn/GroupCommitter.java +++ b/engine/src/main/java/io/hstore/engine/txn/GroupCommitter.java @@ -16,17 +16,23 @@ final class GroupCommitter implements AutoCloseable { interface Barrier { - void makeDurable(int batchSize); + void makeDurable(int batchSize, boolean wrotePages); } static final class Pending { private final Generation generation; private final Runnable publication; + private final boolean wrotePages; private boolean published; Pending(Generation generation, Runnable publication) { + this(generation, publication, true); + } + + Pending(Generation generation, Runnable publication, boolean wrotePages) { this.generation = generation; this.publication = publication; + this.wrotePages = wrotePages; } Generation generation() { @@ -105,7 +111,7 @@ private void advance() { boolean interrupted = Thread.interrupted(); lock.unlock(); try { - barrier.makeDurable(batch.size()); + barrier.makeDurable(batch.size(), batch.stream().anyMatch(pending -> pending.wrotePages)); for (Pending pending : batch) { pending.publication.run(); interrupted |= Thread.interrupted(); diff --git a/engine/src/main/java/io/hstore/engine/txn/ReferenceTracker.java b/engine/src/main/java/io/hstore/engine/txn/ReferenceTracker.java index 57ecf01..43fdb9a 100644 --- a/engine/src/main/java/io/hstore/engine/txn/ReferenceTracker.java +++ b/engine/src/main/java/io/hstore/engine/txn/ReferenceTracker.java @@ -3,13 +3,12 @@ package io.hstore.engine.txn; -import io.hstore.engine.catalog.Generation; public interface ReferenceTracker { ReferenceTracker NONE = new ReferenceTracker() { @Override - public void committed(Generation before, Generation after, long[] spilled) { + public void provisional(long generation, long[] pages) { } @Override @@ -17,7 +16,7 @@ public void discarded(long[] spilled) { } }; - void committed(Generation before, Generation after, long[] spilled); + void provisional(long generation, long[] pages); void discarded(long[] spilled); } diff --git a/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java b/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java index eeae9b8..765f9da 100644 --- a/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java +++ b/engine/src/main/java/io/hstore/engine/txn/TransactionManager.java @@ -23,6 +23,7 @@ import io.hstore.engine.tree.PagedNodeSource; import io.hstore.engine.tree.Ref; import io.hstore.engine.tree.Tree; +import io.hstore.engine.tree.Epoch; import io.hstore.engine.tree.TreeSchema; import io.hstore.engine.tree.WriteScope; import io.hstore.engine.wal.WalRecord; @@ -31,17 +32,20 @@ import java.lang.foreign.MemorySegment; import java.lang.foreign.ValueLayout; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.OptionalLong; +import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.ConcurrentSkipListMap; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.LongAdder; +import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; import java.util.function.LongConsumer; @@ -54,7 +58,7 @@ public final class TransactionManager { public record Storage(PageStore pages, PagedNodeSource source, WriteAheadLog wal, ChangeFeed feed, FeedCodec feedCodec, SlotRegistry slots, List derivations, Durability durability, WalMode walMode, - CrashPoint.Injector faults, int historyLimit, ReferenceTracker references) { + CrashPoint.Injector faults, int historyLimit, ReferenceTracker references, long writeBufferBytes) { } public record Statistics(long commits, long rebases, long conflicts, long readOnly, long pagesWritten, long walBytes, @@ -68,6 +72,8 @@ private record PageWrite(long pageId, MemorySegment image) { } private static final long[] NO_PAGES = new long[0]; + private static final long ROOM_WAIT_NANOS = 30_000_000_000L; + private static final long ROOM_POLL_NANOS = 50_000_000L; private final Storage storage; private final List derivations; @@ -77,6 +83,14 @@ private record PageWrite(long pageId, MemorySegment image) { private final ConcurrentSkipListMap history = new ConcurrentSkipListMap<>(); private final ConcurrentSkipListMap pins = new ConcurrentSkipListMap<>(); private final ReentrantLock commitLock = new ReentrantLock(); + private final ConcurrentSkipListMap commitEnds = new ConcurrentSkipListMap<>(); + private final AtomicLong unwritten = new AtomicLong(); + private volatile Epoch published = publishedEpoch(new Epoch()); + private final ReentrantLock roomLock = new ReentrantLock(); + private final Condition room = roomLock.newCondition(); + private volatile Runnable full = () -> { + }; + private volatile boolean stopped; private final AtomicLong atoms; private final AtomicLong txns; private final LongAdder commits = new LongAdder(); @@ -96,6 +110,7 @@ public TransactionManager(Storage storage, Generation recovered, Collection history.put(generation.id(), generation)); history.put(recovered.id(), recovered); + commitEnds.put(recovered.id(), storage.wal().end()); } public PagedNodeSource source() { @@ -196,6 +211,7 @@ CommitResult commit(Transaction txn) { readOnly.increment(); return new CommitResult(txn.id(), txn.generation(), CommitResult.Outcome.READ_ONLY, 0, 0, 0, 0); } + awaitRoom(); Appended appended; boolean fast; Workspace workspace; @@ -228,7 +244,7 @@ CommitResult commit(Transaction txn) { _ -> Optional.of(new RequestRecord(id, txn.id(), latest.id() + 1)))); RootVector merged = fast ? workspace.rebasedOnto(branch.roots()) : workspace.roots(); appended = append(latest, branch.withRoots(merged), txn.id(), workspace.memberChanges(), workspace.slotChanges(), - List.of(), txn.options().maxPages() - txn.spilledPages(), txn.spilled()); + List.of(), txn.options().maxPages() - txn.spilledPages(), txn.spilled(), workspace.scope()); } finally { commitLock.unlock(); } @@ -263,7 +279,7 @@ public T system(int branchId, Function work) { RootVector roots = workspace.roots(); if (!roots.sameAs(branch.roots())) { appended = Optional.of(append(latest, branch.withRoots(roots), txns.getAndIncrement(), workspace.memberChanges(), - workspace.slotChanges(), List.of(), Long.MAX_VALUE, NO_PAGES)); + workspace.slotChanges(), List.of(), Long.MAX_VALUE, NO_PAGES, workspace.scope())); } } finally { commitLock.unlock(); @@ -286,7 +302,7 @@ public Branch createBranch(String name, int parentId) { int id = new TreeSet<>(latest.branches().keySet()).last() + 1; long txn = txns.getAndIncrement(); created = new Branch(id, name, parentId, latest.id(), System.currentTimeMillis(), Branch.State.ACTIVE, parent.roots()); - appended = append(latest, created, txn, List.of(), List.of(), List.of(meta(txn, created)), Long.MAX_VALUE, NO_PAGES); + appended = append(latest, created, txn, List.of(), List.of(), List.of(meta(txn, created)), Long.MAX_VALUE, NO_PAGES, null); } finally { commitLock.unlock(); } @@ -305,7 +321,7 @@ public void closeBranch(int id, Branch.State state) { Generation latest = head.get(); long txn = txns.getAndIncrement(); Branch closed = latest.branch(id).withState(state).withRoots(RootVector.EMPTY); - appended = append(latest, closed, txn, List.of(), List.of(), List.of(meta(txn, closed)), Long.MAX_VALUE, NO_PAGES); + appended = append(latest, closed, txn, List.of(), List.of(), List.of(meta(txn, closed)), Long.MAX_VALUE, NO_PAGES, null); } finally { commitLock.unlock(); } @@ -328,6 +344,8 @@ public T exclusive(Supplier action) { } public void shutdown() { + stopped = true; + signalRoom(); commitLock.lock(); try { committer.close(); @@ -365,6 +383,10 @@ Ref spill(long txnId, Ref ref, TreeSchema schema, LongConsumer written) { } private Materializer materializer(long epoch, long txnId, List records, List writes) { + return materializer(epoch, txnId, records, writes, null); + } + + private Materializer materializer(long epoch, long txnId, List records, List writes, Epoch stamping) { return new Materializer(new Materializer.Sink() { @Override public long allocate(int length) { @@ -379,35 +401,35 @@ public void accept(long pageId, MemorySegment image, Node frozen) { writes.add(new PageWrite(pageId, image)); storage.source().admit(pageId, frozen); } - }, epoch, storage.pages().pageSize()); + }, epoch, storage.pages().pageSize(), stamping, stamping); } private Appended append(Generation latest, Branch changed, long txnId, List members, - List> slotChanges, List meta, long pageBudget, long[] spilled) { - long pendingPages = changed.roots().roots().entrySet().stream() - .mapToLong(entry -> Materializer.pendingNodes(entry.getValue(), storage.slots().slot(entry.getKey()).schema())) - .sum(); - if (pendingPages > pageBudget) { - throw HStoreException.limit("transaction " + txnId + " would write " + pendingPages + " pages, over its quota"); + List> slotChanges, List meta, long pageBudget, long[] spilled, WriteScope scope) { + long unwrittenNodes = 0; + long unwrittenHeap = 0; + if (scope != null) { + for (Map.Entry root : changed.roots().roots().entrySet()) { + Materializer.Unwritten unwritten = Materializer.unwritten(root.getValue(), storage.slots().slot(root.getKey()).schema(), scope); + unwrittenNodes += unwritten.nodes(); + unwrittenHeap += unwritten.heapBytes(); + } + } + if (unwrittenNodes > pageBudget) { + throw HStoreException.limit("transaction " + txnId + " would write " + unwrittenNodes + " pages, over its quota"); } long generationId = latest.id() + 1; List records = new ArrayList<>(); List writes = new ArrayList<>(); records.add(new WalRecord.Begin(txnId, changed.id(), latest.id())); - Materializer materializer = materializer(generationId, txnId, records, writes); - RootVector stored = changed.roots().map((slot, ref) -> materializer.materialize(ref, storage.slots().slot(slot).schema())); - storage.faults().reach(CrashPoint.PAGE); + Materializer grafts = materializer(generationId, txnId, records, writes, published); Branch previous = latest.branches().get(changed.id()); RootVector before = previous == null ? RootVector.EMPTY : previous.roots(); - new TreeSet<>(Stream.concat(before.roots().keySet().stream(), stored.roots().keySet().stream()).toList()).forEach(slot -> { - if (!Ref.same(before.get(slot), stored.get(slot))) { - records.add(new WalRecord.Root(txnId, changed.id(), slot, stored.get(slot))); - } - }); if (meta.isEmpty()) { records.add(new WalRecord.Changes(txnId, changed.id(), ChangeLog.encode(before, changed.roots(), storage.slots(), storage.source(), - (pending, _) -> materializer.writtenAs(pending.node())))); + grafts::materialize))); } + storage.faults().reach(CrashPoint.PAGE); records.addAll(meta); long wallTime = System.currentTimeMillis(); CommitEvent event = new CommitEvent(generationId, txnId, wallTime, changed.id(), members, slotChanges); @@ -419,32 +441,137 @@ private Appended append(Generation latest, Branch changed, long txnId, List storage.pages().write(write.pageId(), write.image())); storage.faults().reach(CrashPoint.DATA_WRITE); - Generation next = latest.successor(txnId, wallTime, atoms.get(), txns.get(), changed.withRoots(stored)); + Generation next = latest.successor(txnId, wallTime, atoms.get(), txns.get(), changed); storage.wal().append(new WalRecord.Commit(txnId, next.id(), wallTime, next.nextAtom(), next.nextTxn())); storage.faults().reach(CrashPoint.COMMIT_APPEND); + long end = storage.wal().end(); + commitEnds.put(next.id(), end); head.set(next); - long written = storage.wal().end() - walStart; - pagesWritten.add(materializer.pagesWritten()); + unwritten.addAndGet(unwrittenHeap); + long written = end - walStart; + pagesWritten.add(grafts.pagesWritten()); walBytes.add(written); + long[] provisional = provisional(spilled, writes); GroupCommitter.Pending pending = new GroupCommitter.Pending(next, () -> { history.put(next.id(), next); current.set(next); - storage.references().committed(latest, next, spilled); + storage.references().provisional(next.id(), provisional); if (!event.isEmpty()) { storage.feed().append(event); } trimHistory(); storage.faults().reach(CrashPoint.CATALOG_PUBLISH); - }); + }, provisional.length > 0); committer.submit(pending); - return new Appended(pending, materializer.pagesWritten(), written); + return new Appended(pending, grafts.pagesWritten(), written); + } + + private static long[] provisional(long[] spilled, List writes) { + if (writes.isEmpty()) { + return spilled; + } + long[] pages = Arrays.copyOf(spilled, spilled.length + writes.size()); + for (int i = 0; i < writes.size(); i++) { + pages[spilled.length + i] = writes.get(i).pageId(); + } + return pages; + } + + public void whenFull(Runnable action) { + full = action; + } + + public long recountUnwritten() { + long bytes = head.get().branches().values().stream() + .flatMap(branch -> branch.roots().roots().values().stream()) + .mapToLong(Materializer::unwrittenHeap) + .sum(); + unwritten.set(bytes); + return bytes; + } + + private void awaitRoom() { + if (unwritten.get() <= storage.writeBufferBytes() || recountUnwritten() <= storage.writeBufferBytes()) { + return; + } + full.run(); + long deadline = System.nanoTime() + ROOM_WAIT_NANOS; + roomLock.lock(); + try { + while (unwritten.get() > storage.writeBufferBytes() && !stopped) { + long left = deadline - System.nanoTime(); + if (left <= 0) { + LOG.log(System.Logger.Level.WARNING, "the write buffer stayed full for {0} s; committing anyway", + ROOM_WAIT_NANOS / 1_000_000_000L); + return; + } + room.awaitNanos(Math.min(left, ROOM_POLL_NANOS)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + roomLock.unlock(); + } + } + + private void signalRoom() { + roomLock.lock(); + try { + room.signalAll(); + } finally { + roomLock.unlock(); + } + } + + public Epoch publishedEpoch() { + return published; + } + + private static Epoch publishedEpoch(Epoch epoch) { + epoch.publish(); + return epoch; + } + + public long unwrittenBytes() { + return unwritten.get(); + } + + public long commitEnd(long generation) { + Long end = commitEnds.get(generation); + if (end == null) { + throw new IllegalStateException("the log end of generation " + generation + " is not known"); + } + return end; + } + + public void checkpointed(Generation materialized, Epoch epoch) { + epoch.publish(); + published = epoch; + commitEnds.headMap(materialized.id(), false).clear(); + exclusive(() -> { + rebind(epoch); + return null; + }); + recountUnwritten(); + signalRoom(); + } + + private void rebind(Epoch epoch) { + Generation latest = head.get(); + Map branches = new TreeMap<>(); + latest.branches().forEach((id, branch) -> branches.put(id, branch.withRoots(branch.roots().map((slot, ref) -> + Materializer.rebound(ref, storage.slots().slot(slot).schema(), epoch))))); + Generation rebound = new Generation(latest.id(), latest.wallTime(), latest.txnId(), latest.nextAtom(), latest.nextTxn(), branches); + head.set(rebound); + current.set(rebound); + history.put(rebound.id(), rebound); } - private void makeDurable(int batchSize) { + private void makeDurable(int batchSize, boolean wrotePages) { if (storage.durability() != Durability.SYNC) { return; } - if (storage.walMode() == WalMode.PAGE_REFERENCES) { + if (wrotePages && storage.walMode() == WalMode.PAGE_REFERENCES) { storage.pages().sync(); } storage.faults().reach(CrashPoint.DATA_SYNC); diff --git a/engine/src/test/java/io/hstore/engine/CompactionTest.java b/engine/src/test/java/io/hstore/engine/CompactionTest.java index 3bd2541..5e37647 100644 --- a/engine/src/test/java/io/hstore/engine/CompactionTest.java +++ b/engine/src/test/java/io/hstore/engine/CompactionTest.java @@ -20,6 +20,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -45,7 +46,8 @@ class CompactionTest { Path directory; private static EngineOptions options() { - return EngineTest.small().withPagesPerSegment(64).withHistoryLimit(2).withCheckpointWalBytes(Long.MAX_VALUE); + return EngineTest.small().withPagesPerSegment(64).withHistoryLimit(2).withCheckpointWalBytes(Long.MAX_VALUE) + .withMaintenanceInterval(Duration.ofHours(1)); } @Test @@ -82,17 +84,21 @@ void retainedHistoryStaysReadableOnceItsSegmentsAreDeleted() { written.forEach(member -> txn.insert(created, member)); return created; }); + engine.checkpoint(); Set early = segmentIds(engine); edge = churn(engine); earlier = engine.stats().generation() - 10; Compactor.Report report = engine.compact(); assertTrue(report.compacted().stream().anyMatch(early::contains), "no early segment was compacted: " + report.compacted()); report.compacted().forEach(victim -> assertFalse(segmentIds(engine).contains(victim))); + try (Snapshot old = engine.snapshotAt(earlier, 0)) { + assertEquals(written, old.requireEdge(large).stream().map(Incidence::member).toList()); + assertEquals(afterRound(ROUNDS - 11), old.requireEdge(edge).stream().map(Incidence::member).toList()); + } } - try (StorageEngine engine = StorageEngine.open(directory, options); - Snapshot old = engine.snapshotAt(earlier, 0)) { - assertEquals(written, old.requireEdge(large).stream().map(Incidence::member).toList()); - assertEquals(afterRound(ROUNDS - 11), old.requireEdge(edge).stream().map(Incidence::member).toList()); + try (StorageEngine engine = StorageEngine.open(directory, options)) { + assertEquals(written, engine.read(snapshot -> snapshot.requireEdge(large).stream().map(Incidence::member).toList())); + assertEquals(afterRound(ROUNDS - 1), engine.read(snapshot -> snapshot.requireEdge(edge).stream().map(Incidence::member).toList())); } } @@ -140,10 +146,13 @@ void commitsCarryOnWhilePagesMove() throws Exception { } return null; }); - for (int i = 0; i < 3; i++) { - engine.compact(); + try { + for (int i = 0; i < 3; i++) { + engine.compact(); + } + } finally { + compacting.set(false); } - compacting.set(false); writes.get(); } assertEquals(conflicts, engine.stats().conflicts()); @@ -269,6 +278,7 @@ private static void churn(StorageEngine engine, long edge) { } return null; }); + engine.checkpoint(); } } diff --git a/engine/src/test/java/io/hstore/engine/CrashRecoveryTest.java b/engine/src/test/java/io/hstore/engine/CrashRecoveryTest.java index cbed659..f1ee721 100644 --- a/engine/src/test/java/io/hstore/engine/CrashRecoveryTest.java +++ b/engine/src/test/java/io/hstore/engine/CrashRecoveryTest.java @@ -30,6 +30,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.IntStream; +import java.util.stream.LongStream; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -99,9 +100,9 @@ void recoveryStopsAtTheFirstCommitWhosePagesAreMissing() throws Exception { engine.write(txn -> txn.createNode(EngineTest.NODE, "durable")); } StorageEngine engine = StorageEngine.open(directory, options); - engine.write(txn -> txn.createNode(EngineTest.NODE, "lost-1")); + loadedNode(engine, "lost-1"); long beforeSecond = Files.size(lastSegment()); - engine.write(txn -> txn.createNode(EngineTest.NODE, "lost-2")); + loadedNode(engine, "lost-2"); engine.halt(); try (FileChannel segment = FileChannel.open(lastSegment(), StandardOpenOption.WRITE)) { segment.truncate(beforeSecond - 1); @@ -120,6 +121,15 @@ void recoveryStopsAtTheFirstCommitWhosePagesAreMissing() throws Exception { } } + private static void loadedNode(StorageEngine engine, String key) { + engine.write(txn -> { + txn.createNode(EngineTest.NODE, key); + long edge = txn.createEdge(EngineTest.EDGE, EdgeKind.SET); + txn.load(edge, LongStream.range(1_000_000, 1_020_000).mapToObj(Incidence::of).toList()); + return null; + }); + } + @ParameterizedTest @EnumSource(WalMode.class) void aBranchCreatedSinceTheLastCheckpointComesBackFromTheLog(WalMode mode) { diff --git a/engine/src/test/java/io/hstore/engine/DeferredWritesTest.java b/engine/src/test/java/io/hstore/engine/DeferredWritesTest.java new file mode 100644 index 0000000..fe43b40 --- /dev/null +++ b/engine/src/test/java/io/hstore/engine/DeferredWritesTest.java @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Metacog Labs +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package io.hstore.engine; + +import io.hstore.engine.topology.EdgeKind; +import io.hstore.engine.topology.Incidence; +import io.hstore.engine.tree.Ref; +import io.hstore.engine.txn.CommitResult; +import io.hstore.engine.txn.CrashPoint; +import io.hstore.engine.txn.Transaction; +import io.hstore.engine.txn.View; + +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.EnumSource; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DeferredWritesTest { + + @TempDir + Path directory; + + private static EngineOptions options() { + return EngineTest.small().withCheckpointWalBytes(Long.MAX_VALUE).withMaintenanceInterval(Duration.ofHours(1)); + } + + @Test + void smallCommitsWriteNoPagesAndACheckpointWritesEachNodeOnce() { + try (StorageEngine engine = StorageEngine.open(directory, options())) { + long edge = engine.write(txn -> txn.createEdge(EngineTest.EDGE, EdgeKind.SET)); + engine.checkpoint(); + long before = engine.stats().pagesWritten(); + for (int i = 0; i < 300; i++) { + long member = i; + Transaction txn = engine.begin(); + txn.insert(edge, member); + CommitResult result = txn.commit(); + assertEquals(0, result.pagesWritten(), "commit " + i); + } + assertEquals(before, engine.stats().pagesWritten()); + engine.checkpoint(); + long written = engine.stats().pagesWritten() - before; + assertTrue(written > 0 && written < 60, "a checkpoint after 300 single inserts wrote " + written + " pages"); + assertEquals(300L, (long) engine.read(snapshot -> snapshot.cardinality(edge))); + engine.transactions().current().branches().values().forEach(branch -> branch.roots().roots().values() + .forEach(root -> assertTrue(root instanceof Ref.Stored, "a checkpointed root is still " + root))); + engine.verifyReferences(); + } + try (StorageEngine engine = StorageEngine.open(directory, options())) { + long only = engine.read(DeferredWritesTest::onlyEdge); + assertEquals(300L, (long) engine.read(snapshot -> snapshot.cardinality(only))); + engine.verifyReferences(); + } + } + + @Test + void theNextCheckpointKeepsThePagesABulkLoadWrote() { + try (StorageEngine engine = StorageEngine.open(directory, options())) { + long edge = engine.write(txn -> txn.createEdge(EngineTest.EDGE, EdgeKind.SET)); + engine.checkpoint(); + long before = engine.stats().pagesWritten(); + engine.write(txn -> { + txn.load(edge, LongStream.range(0, 20_000).mapToObj(Incidence::of).toList()); + return null; + }); + long loaded = engine.stats().pagesWritten() - before; + assertTrue(loaded > 100, "the load wrote only " + loaded + " pages"); + engine.checkpoint(); + long rewritten = engine.stats().pagesWritten() - before - loaded; + assertTrue(rewritten < 10, "the checkpoint wrote " + rewritten + " pages after a load of " + loaded); + engine.verifyReferences(); + } + } + + @ParameterizedTest + @EnumSource(value = CrashPoint.class, names = {"CHECKPOINT_PAGES", "CHECKPOINT_CATALOG", "CHECKPOINT_JOURNAL"}) + void aCheckpointCutShortWhileCommitsContinueLosesNoAcknowledgedCommit(CrashPoint point) throws Exception { + AtomicBoolean armed = new AtomicBoolean(false); + EngineOptions options = options().withFaults(reached -> { + if (reached == point && armed.compareAndSet(true, false)) { + throw new CrashPoint.SimulatedCrash(point); + } + }); + ConcurrentLinkedQueue acknowledged = new ConcurrentLinkedQueue<>(); + long edge; + StorageEngine engine = StorageEngine.open(directory, options); + edge = engine.write(txn -> txn.createEdge(EngineTest.EDGE, EdgeKind.SET)); + for (long member = 0; member < 200; member++) { + long added = member; + engine.write(txn -> { + txn.insert(edge, added); + return null; + }); + acknowledged.add(added); + } + engine.checkpoint(); + AtomicBoolean writing = new AtomicBoolean(true); + try (ExecutorService writer = Executors.newSingleThreadExecutor()) { + Future writes = writer.submit(() -> { + for (long member = 1_000; writing.get(); member++) { + long added = member; + try { + engine.write(txn -> { + txn.insert(edge, added); + return null; + }); + } catch (RuntimeException stopped) { + return; + } + acknowledged.add(added); + } + }); + try { + for (int i = 0; i < 20; i++) { + engine.checkpoint(); + } + armed.set(true); + assertThrows(CrashPoint.SimulatedCrash.class, engine::checkpoint); + } finally { + writing.set(false); + writes.get(); + } + } + engine.halt(); + try (StorageEngine recovered = StorageEngine.open(directory, options())) { + List members = recovered.read(snapshot -> snapshot.requireEdge(edge).memberIds().boxed().toList()); + assertTrue(members.containsAll(acknowledged), "lost " + acknowledged.stream().filter(m -> !members.contains(m)).limit(5).toList()); + assertEquals(acknowledged.size(), members.size()); + recovered.verifyReferences(); + } + try (StorageEngine reopened = StorageEngine.open(directory, options())) { + assertEquals(acknowledged.size(), (long) reopened.read(snapshot -> snapshot.cardinality(edge))); + reopened.verifyReferences(); + } + } + + @Test + void aFullWriteBufferMakesCommitsWaitForACheckpoint() { + EngineOptions options = options().withWriteBufferBytes(256 * 1024); + long edge; + try (StorageEngine engine = StorageEngine.open(directory, options)) { + edge = engine.write(txn -> txn.createEdge(EngineTest.EDGE, EdgeKind.SET)); + long started = System.nanoTime(); + for (long member = 0; member < 3_000; member++) { + long added = member; + engine.write(txn -> { + txn.insert(edge, added); + return null; + }); + assertTrue(engine.transactions().unwrittenBytes() < 2 * options.writeBufferBytes(), + "unwritten bytes " + engine.transactions().unwrittenBytes()); + } + assertTrue(System.nanoTime() - started < Duration.ofSeconds(60).toNanos()); + assertTrue(engine.stats().pagesWritten() > 0, "the full buffer never triggered a checkpoint"); + } + try (StorageEngine engine = StorageEngine.open(directory, options)) { + assertEquals(3_000L, (long) engine.read(snapshot -> snapshot.cardinality(edge))); + engine.verifyReferences(); + } + } + + private static long onlyEdge(View view) { + return view.atomsOfType(0, EngineTest.EDGE).findFirst().orElseThrow(); + } +} diff --git a/engine/src/test/java/io/hstore/engine/EngineTest.java b/engine/src/test/java/io/hstore/engine/EngineTest.java index 3e26ec3..507ab8c 100644 --- a/engine/src/test/java/io/hstore/engine/EngineTest.java +++ b/engine/src/test/java/io/hstore/engine/EngineTest.java @@ -11,6 +11,7 @@ import io.hstore.engine.page.PageHeader; import io.hstore.engine.page.PageId; import io.hstore.engine.page.SegmentInfo; +import io.hstore.engine.page.SegmentState; import io.hstore.engine.topology.EdgeKind; import io.hstore.engine.topology.Hyperedge; import io.hstore.engine.topology.Incidence; @@ -34,6 +35,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -525,7 +527,7 @@ void giantBulkLoadSpillsAndSupportsAlgebra() { @Test void compactionReclaimsChurnedSegments() { - EngineOptions options = small().withPagesPerSegment(64).withHistoryLimit(2); + EngineOptions options = small().withPagesPerSegment(64).withHistoryLimit(2).withMaintenanceInterval(Duration.ofHours(1)); try (StorageEngine engine = StorageEngine.open(directory, options)) { long edge = engine.write(txn -> txn.createEdge(EDGE, EdgeKind.SET)); LinkedHashSet oracle = new LinkedHashSet<>(); @@ -548,6 +550,7 @@ void compactionReclaimsChurnedSegments() { oracle.remove(10_000L + base - 100 + i); } } + engine.checkpoint(); } int before = engine.stats().segments().size(); engine.compact(); @@ -562,6 +565,10 @@ void compactionReclaimsChurnedSegments() { long bytes = live.getOrDefault(segment.id(), 0L); assertEquals(0, bytes % PageId.UNIT_BYTES); assertTrue(bytes <= segment.bytes(), "segment " + segment.id() + " live " + bytes + " of " + segment.bytes()); + if (segment.state() == SegmentState.SEALED) { + assertTrue(bytes >= segment.bytes() * options.compactionLiveRatio(), + "sealed segment " + segment.id() + " is only " + bytes + " of " + segment.bytes() + " bytes live"); + } } engine.read(snapshot -> { assertEquals(List.copyOf(new TreeSet<>(oracle)), snapshot.requireEdge(edge).stream().map(Incidence::member).toList()); @@ -607,10 +614,10 @@ void dictionaryInternsRolesAcrossRestarts() { } @Test - void directoriesFromTheReferenceCountFormatAreRefused() throws IOException { - Files.writeString(directory.resolve("FORMAT"), "format=5\npage-size=1024\n"); + void directoriesFromTheInlineMemberFormatAreRefused() throws IOException { + Files.writeString(directory.resolve("FORMAT"), "format=6\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"), + assertTrue(refused.getMessage().endsWith("uses storage format 6; this build reads format 7 (commits logged as key changes); export and reload it"), refused.getMessage()); } } diff --git a/engine/src/test/java/io/hstore/engine/InlineMembersTest.java b/engine/src/test/java/io/hstore/engine/InlineMembersTest.java index b56eda4..2b3dacd 100644 --- a/engine/src/test/java/io/hstore/engine/InlineMembersTest.java +++ b/engine/src/test/java/io/hstore/engine/InlineMembersTest.java @@ -315,6 +315,7 @@ void compactionCarriesInlineEdgesAlong() { }); model.get(edge).remove(first); } + engine.checkpoint(); } Compactor.Report report = engine.compact(); assertFalse(report.compacted().isEmpty()); @@ -344,8 +345,6 @@ void historyAndTheFeedFollowAnEdgeAcrossTheThreshold() { 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)); } @@ -441,6 +440,7 @@ private static long setEdge(StorageEngine engine, List members) { } private static EdgeRecord record(StorageEngine engine, long edge) { + engine.checkpoint(); return engine.read(snapshot -> record(snapshot, edge)); } diff --git a/engine/src/test/java/io/hstore/engine/ReferenceCountTest.java b/engine/src/test/java/io/hstore/engine/ReferenceCountTest.java index 6f69971..6d0004b 100644 --- a/engine/src/test/java/io/hstore/engine/ReferenceCountTest.java +++ b/engine/src/test/java/io/hstore/engine/ReferenceCountTest.java @@ -152,6 +152,7 @@ void pagesAreReleasedOnlyOnceNoReaderCanReachThem() { LongStream.range(0, 3_000).forEach(member -> txn.insert(edge, member)); return null; }); + engine.checkpoint(); long full; try (Snapshot reader = engine.snapshot()) { for (int round = 0; round < 30; round++) { @@ -209,6 +210,7 @@ void pagesALoadSpillsAndTheSameTransactionReplacesAreGivenBack() { txn.delete(edge); return null; }); + engine.checkpoint(); for (int i = 0; i < 4; i++) { engine.write(txn -> txn.createNode(EngineTest.NODE, "after-" + txn.id())); } 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 dc9b57e..abc1695 100644 --- a/engine/src/test/java/io/hstore/engine/tree/TreeTest.java +++ b/engine/src/test/java/io/hstore/engine/tree/TreeTest.java @@ -341,7 +341,11 @@ void inlineReferencesAreTheSameOnlyWhenTheirEntriesAre() { 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)))); + assertTrue(Ref.same(a, new Ref.Pending(memberLeaf(1, 2, 3)))); + assertTrue(Ref.same(new Ref.Pending(memberLeaf(1, 2, 3)), a)); + assertTrue(Ref.same(new Ref.Pending(memberLeaf(1, 2, 3)), new Ref.Pending(memberLeaf(1, 2, 3)))); + assertFalse(Ref.same(a, new Ref.Pending(memberLeaf(1, 2, 4)))); + assertFalse(Ref.same(new Ref.Pending(memberLeaf(1, 2, 3)), new Ref.Pending(memberLeaf(1, 2, 4)))); assertFalse(Ref.same(a, Ref.EMPTY)); } diff --git a/engine/src/test/java/io/hstore/engine/txn/GroupCommitterTest.java b/engine/src/test/java/io/hstore/engine/txn/GroupCommitterTest.java index 0123315..3501154 100644 --- a/engine/src/test/java/io/hstore/engine/txn/GroupCommitterTest.java +++ b/engine/src/test/java/io/hstore/engine/txn/GroupCommitterTest.java @@ -57,7 +57,7 @@ GroupCommitter.Pending append(Runnable also) { @Test void aLoneCommitIsPublishedOnItsOwnThread() { - GroupCommitter committer = new GroupCommitter(_ -> { + GroupCommitter committer = new GroupCommitter((_, _) -> { }); Appender appender = new Appender(committer); List publishers = new ArrayList<>(); @@ -70,7 +70,7 @@ void aLoneCommitIsPublishedOnItsOwnThread() { @Test void concurrentCommitsArePublishedInOrderAndShareBarriers() throws Exception { AtomicInteger barriers = new AtomicInteger(); - GroupCommitter committer = new GroupCommitter(_ -> { + GroupCommitter committer = new GroupCommitter((_, _) -> { barriers.incrementAndGet(); try { Thread.sleep(1); @@ -99,7 +99,7 @@ void concurrentCommitsArePublishedInOrderAndShareBarriers() throws Exception { @Test void aFailedBarrierFailsEveryCommitAfterIt() { AtomicInteger calls = new AtomicInteger(); - GroupCommitter committer = new GroupCommitter(_ -> { + GroupCommitter committer = new GroupCommitter((_, _) -> { if (calls.incrementAndGet() == 2) { throw new IllegalStateException("disk full"); } @@ -127,7 +127,7 @@ void anInterruptedCommitterLeadsABatchWithoutClosingTheLog(@TempDir Path dir) th throw new UncheckedIOException(e); } }; - GroupCommitter committer = new GroupCommitter(_ -> write.run()); + GroupCommitter committer = new GroupCommitter((_, _) -> write.run()); Appender appender = new Appender(committer); appender.append(() -> Thread.currentThread().interrupt()); GroupCommitter.Pending second = appender.append(write); From 0c1ab4f9aa854ddf6e5f4aab7d0f45761d0b6ba0 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Mon, 12 Oct 2026 04:53:57 +0530 Subject: [PATCH 4/9] perf(engine): log a tree whose size changed past the limit by its pages When a tree gained or lost more than 4,096 entries in one commit, the change log still diffed 4,097 entries, and encoded their values, before falling back to writing the tree's pages. The entry counts are on the references already, so check them first and go straight to the pages. --- engine/src/main/java/io/hstore/engine/txn/ChangeLog.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/engine/src/main/java/io/hstore/engine/txn/ChangeLog.java b/engine/src/main/java/io/hstore/engine/txn/ChangeLog.java index 3a05635..2f4108d 100644 --- a/engine/src/main/java/io/hstore/engine/txn/ChangeLog.java +++ b/engine/src/main/java/io/hstore/engine/txn/ChangeLog.java @@ -72,6 +72,11 @@ private static void writeTree(ByteCursor out, TreeSchema schema, Ref base Ref.write(out, after); return; } + if (Math.abs(after.count() - base.count()) > MAX_DELTA) { + out.putByte(ROOT); + Ref.write(out, durable.of(pending, schema)); + return; + } List> deltas = TreeDiff.diff(new Tree<>(schema, source, base), new Tree<>(schema, source, after)) .limit(MAX_DELTA + 1) .toList(); From 75295a69630e51afb27f38480a3f145909a5b6b1 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Mon, 12 Oct 2026 04:53:57 +0530 Subject: [PATCH 5/9] feat(engine): let compaction seal an active segment that is mostly dead Checkpoints write far less than commits did, so a 256 MiB segment can take a long time to fill. Compaction only takes sealed segments, so the dead pages in the one still being written stayed on disk. In the scale 1 benchmark, disk after a full compaction was 234 MB before this change and 95 MB with it. When the active segment's live bytes are below compaction_live_ratio, seal it so the pass can move its pages out. COMPACT does this at any size. The background pass waits until the segment holds a quarter of its capacity, so it doesn't leave a trail of small files. --- .../hstore/engine/maintenance/Compactor.java | 18 ++++++++++++++++- .../java/io/hstore/engine/page/PageStore.java | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/engine/src/main/java/io/hstore/engine/maintenance/Compactor.java b/engine/src/main/java/io/hstore/engine/maintenance/Compactor.java index f684f42..5ef6dda 100644 --- a/engine/src/main/java/io/hstore/engine/maintenance/Compactor.java +++ b/engine/src/main/java/io/hstore/engine/maintenance/Compactor.java @@ -18,6 +18,8 @@ public final class Compactor { + private static final long ACTIVE_FRACTION = 4; + public record Report(Map liveBytes, List compacted, long generation) { } @@ -35,7 +37,20 @@ public Compactor(TransactionManager transactions, PageStore pages, Checkpointer } public boolean worthwhile(double liveThreshold) { - return !victims(pages.liveBytes(), liveThreshold, 1).isEmpty(); + return !victims(pages.liveBytes(), liveThreshold, 1).isEmpty() || activeMostlyDead(liveThreshold, pages.segmentBytes() / ACTIVE_FRACTION); + } + + private void sealActiveIfMostlyDead(double liveThreshold, long minimumBytes) { + if (activeMostlyDead(liveThreshold, minimumBytes)) { + pages.sealActive(pages.activeSegment()); + } + } + + private boolean activeMostlyDead(double liveThreshold, long minimumBytes) { + int active = pages.activeSegment(); + return pages.segments().stream() + .filter(segment -> segment.id() == active && segment.bytes() > 0 && segment.bytes() >= minimumBytes) + .anyMatch(segment -> pages.liveBytes().getOrDefault(segment.id(), 0L) < segment.bytes() * liveThreshold); } private List victims(Map liveBytes, double liveThreshold, int maxVictims) { @@ -54,6 +69,7 @@ public Report compact(double liveThreshold) { } public Report compact(double liveThreshold, int maxVictims) { + sealActiveIfMostlyDead(liveThreshold, maxVictims == Integer.MAX_VALUE ? 0 : pages.segmentBytes() / ACTIVE_FRACTION); Map liveBytes = pages.liveBytes(); List victims = victims(liveBytes, liveThreshold, maxVictims); List emptied = new ArrayList<>(); diff --git a/engine/src/main/java/io/hstore/engine/page/PageStore.java b/engine/src/main/java/io/hstore/engine/page/PageStore.java index b55a8df..1ea4cf1 100644 --- a/engine/src/main/java/io/hstore/engine/page/PageStore.java +++ b/engine/src/main/java/io/hstore/engine/page/PageStore.java @@ -449,6 +449,26 @@ public List segments() { return List.copyOf(segments.values()); } + public long segmentBytes() { + return (long) segmentUnits * PageId.UNIT_BYTES; + } + + public void sealActive(int segment) { + allocation.lock(); + try { + if (activeSegment == segment && segment != 0) { + segments.computeIfPresent(activeSegment, (_, info) -> info.withState(SegmentState.SEALED, 0)); + SegmentFile file = files.get(activeSegment); + if (file != null) { + file.seal(); + } + activeSegment = 0; + } + } finally { + allocation.unlock(); + } + } + public int activeSegment() { allocation.lock(); try { From 19b11ad00188bf42af5890105bc583ca32aea71c Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Mon, 12 Oct 2026 04:53:57 +0530 Subject: [PATCH 6/9] feat(server): add write_buffer_mb for memory held by unwritten nodes --- server/src/main/java/io/hstore/server/ServerConfig.java | 1 + server/src/main/java/io/hstore/server/Setting.java | 1 + 2 files changed, 2 insertions(+) diff --git a/server/src/main/java/io/hstore/server/ServerConfig.java b/server/src/main/java/io/hstore/server/ServerConfig.java index 91c4b1f..2014a77 100644 --- a/server/src/main/java/io/hstore/server/ServerConfig.java +++ b/server/src/main/java/io/hstore/server/ServerConfig.java @@ -126,6 +126,7 @@ EngineOptions engineOptions() { .withWalMode(string(Setting.WAL_MODE).equalsIgnoreCase("images") ? WalMode.PAGE_IMAGES : WalMode.PAGE_REFERENCES) .withNodeCacheBytes(megabytes(Setting.NODE_CACHE_MB)) .withCheckpointWalBytes(number(Setting.CHECKPOINT_WAL_MB) << 20) + .withWriteBufferBytes(megabytes(Setting.WRITE_BUFFER_MB)) .withHistoryLimit(integer(Setting.HISTORY_LIMIT)) .withCompactionLiveRatio(Double.parseDouble(string(Setting.COMPACTION_LIVE_RATIO))) .withFeedRetention(number(Setting.FEED_RETENTION_GENERATIONS)); diff --git a/server/src/main/java/io/hstore/server/Setting.java b/server/src/main/java/io/hstore/server/Setting.java index dab1cbe..0dd8423 100644 --- a/server/src/main/java/io/hstore/server/Setting.java +++ b/server/src/main/java/io/hstore/server/Setting.java @@ -26,6 +26,7 @@ enum Setting { WAL_MODE("wal_mode", "references", "references logs page checksums and syncs data first; images logs full page images"), NODE_CACHE_MB("node_cache_mb", "256", "megabytes of heap for decoded tree nodes, each counted at an estimate of its size"), CHECKPOINT_WAL_MB("checkpoint_wal_mb", "256", "write-ahead log volume that triggers a background checkpoint"), + WRITE_BUFFER_MB("write_buffer_mb", "64", "megabytes of committed tree nodes kept in memory until a checkpoint writes them"), HISTORY_LIMIT("history_limit", "64", "committed generations retained for time travel"), COMPACTION_LIVE_RATIO("compaction_live_ratio", "0.5", "sealed segments whose live node images fall below this ratio are compacted"), FEED_RETENTION_GENERATIONS("feed_retention_generations", "100000", "change-feed generations kept for HISTORY and subscribers"), From 94366e0e7686aeb99e3f0246fd425c8e2cf3e10e Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Mon, 12 Oct 2026 05:13:06 +0530 Subject: [PATCH 7/9] fix(engine): keep the checkpointed generation in history after a restart Recovery seeded the history with the catalog image's history and then added the generations it replayed, but never the checkpointed generation itself. After a restart with anything to replay, the oldest generation time travel could reach was the first replayed one, one past the generation the checkpoint had written. --- .../hstore/engine/maintenance/Recovery.java | 1 + .../io/hstore/engine/DeferredWritesTest.java | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java b/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java index 70aab5f..6f54718 100644 --- a/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java +++ b/engine/src/main/java/io/hstore/engine/maintenance/Recovery.java @@ -61,6 +61,7 @@ public static Outcome recover(CatalogImage checkpoint, WriteAheadLog wal, PageSt }); Generation generation = checkpoint.current(); List history = new ArrayList<>(checkpoint.history()); + history.add(generation); List replayed = new ArrayList<>(); int discarded = 0; for (WalRecord.Commit commit : commits) { diff --git a/engine/src/test/java/io/hstore/engine/DeferredWritesTest.java b/engine/src/test/java/io/hstore/engine/DeferredWritesTest.java index fe43b40..eea96c7 100644 --- a/engine/src/test/java/io/hstore/engine/DeferredWritesTest.java +++ b/engine/src/test/java/io/hstore/engine/DeferredWritesTest.java @@ -8,6 +8,7 @@ import io.hstore.engine.tree.Ref; import io.hstore.engine.txn.CommitResult; import io.hstore.engine.txn.CrashPoint; +import io.hstore.engine.txn.Snapshot; import io.hstore.engine.txn.Transaction; import io.hstore.engine.txn.View; @@ -68,6 +69,37 @@ void smallCommitsWriteNoPagesAndACheckpointWritesEachNodeOnce() { } } + @Test + void aRestartKeepsTheCheckpointedGenerationAndTheReplayedOnes() { + long edge; + long checkpointed; + long replayed; + StorageEngine engine = StorageEngine.open(directory, options()); + edge = engine.write(txn -> txn.createEdge(EngineTest.EDGE, EdgeKind.SET)); + engine.write(txn -> { + txn.insert(edge, 1); + return null; + }); + checkpointed = engine.checkpoint().generation(); + engine.write(txn -> { + txn.insert(edge, 2); + return null; + }); + replayed = engine.stats().generation(); + engine.write(txn -> { + txn.insert(edge, 3); + return null; + }); + engine.halt(); + try (StorageEngine reopened = StorageEngine.open(directory, options()); + Snapshot atCheckpoint = reopened.snapshotAt(checkpointed, 0); + Snapshot atReplayed = reopened.snapshotAt(replayed, 0)) { + assertEquals(List.of(1L), atCheckpoint.requireEdge(edge).memberIds().boxed().toList()); + assertEquals(List.of(1L, 2L), atReplayed.requireEdge(edge).memberIds().boxed().toList()); + assertEquals(3L, (long) reopened.read(snapshot -> snapshot.cardinality(edge))); + } + } + @Test void theNextCheckpointKeepsThePagesABulkLoadWrote() { try (StorageEngine engine = StorageEngine.open(directory, options())) { From ec09356b6406ddcb8fcf78cd9d44af3df768f424 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Mon, 12 Oct 2026 05:13:06 +0530 Subject: [PATCH 8/9] docs: describe logged commits, checkpoint writes and the write buffer --- docs/architecture/overview.md | 53 +++---- docs/benchmarks.md | 6 +- docs/database/evidence-and-temporal.md | 6 +- docs/development.md | 2 + docs/operations/configuration.md | 22 ++- docs/operations/docker.md | 4 +- docs/operations/logging.md | 10 +- docs/storage/catalog-and-generations.md | 16 ++- docs/storage/maintenance.md | 176 ++++++++++++++---------- docs/storage/pages.md | 30 ++-- docs/storage/persistent-tree.md | 40 ++++-- docs/transactions/transactions.md | 51 ++++--- docs/transactions/wal-and-recovery.md | 145 +++++++++++-------- 13 files changed, 337 insertions(+), 224 deletions(-) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 6011bf2..b6e01a3 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,6 +1,6 @@ # Architecture overview -HStore is a native, persistent hypergraph store. Every structure it keeps on disk (atoms, hyperedge memberships, reverse incidence, secondary indexes, schema, users) is a copy-on-write counted B+tree stored in fixed-size pages. A commit publishes a new **generation**: an immutable vector of tree roots, one per *slot*. Readers pin a generation and never block writers. Writers build new roots in private and publish them through a write-ahead log and a group-commit barrier. +HStore is a native, persistent hypergraph store. Every structure it keeps on disk (atoms, hyperedge memberships, reverse incidence, secondary indexes, schema, users) is a copy-on-write counted B+tree stored in fixed-size pages. A commit publishes a new **generation**: an immutable vector of tree roots, one per *slot*. Readers pin a generation and never block writers. Writers build new roots in private and publish them through a write-ahead log and a group-commit barrier. A commit logs the keys it changed and keeps its new tree nodes in memory; a checkpoint later writes them to pages, once for all the commits since the previous one. This document maps the code base and follows a read and a write from the API down to the bytes on disk. The other documents cover each layer in depth: @@ -84,14 +84,14 @@ 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`, `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.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` (writes pending nodes to pages at checkpoints), `Epoch` (one checkpoint attempt, for the stamps on written nodes), `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). | -| `engine.txn` | `TransactionManager` (commit lock, append, publication, pins, history), `Transaction` (op log, read sets, rebase), `Snapshot`, `View` (read API shared by both), `Workspace` (mutable root vector plus derivations), `GroupCommitter`, `Op`/`EdgeAction`, `Derivation`/`EngineDerivations`, `CrashPoint`, `Durability`, `WalMode`, `Isolation` and `TxnOptions`. | -| `engine.wal` | `WriteAheadLog` (segmented, checksummed records) and `WalRecord` (`Begin`, `Page`, `PageRef`, `Root`, `BranchMeta`, `Feed`, `Commit`, `Abort`, `Checkpoint`). | +| `engine.txn` | `TransactionManager` (commit lock, append, publication, pins, history, write buffer), `ChangeLog` (the key changes a commit logs, and their replay), `Transaction` (op log, read sets, rebase), `Snapshot`, `View` (read API shared by both), `Workspace` (mutable root vector plus derivations), `GroupCommitter`, `Op`/`EdgeAction`, `Derivation`/`EngineDerivations`, `CrashPoint`, `Durability`, `WalMode`, `Isolation` and `TxnOptions`. | +| `engine.wal` | `WriteAheadLog` (segmented, checksummed records) and `WalRecord` (`Begin`, `Page`, `PageRef`, `Changes`, `BranchMeta`, `Feed`, `Commit`, `Abort`, `Checkpoint`, and `Root`, which is still read but no longer written). | | `engine.feed` | `ChangeFeed` (segmented append-only `feed/*.feed` files, sparse generation index, retention, tailing subscriptions), `CommitEvent` and `FeedCodec`; see [change-feed.md](../storage/change-feed.md). | -| `engine.maintenance` | `Checkpointer`, `ReferenceCounter` (counts references after each commit, releases pages no reader can reach), `Compactor` (moving live pages out of victim segments, reclamation) and `Recovery` (catalog discovery, WAL redo, durable-prefix rule). | +| `engine.maintenance` | `Checkpointer` (writes a published generation's trees to pages and publishes the catalog image), `ReferenceCounter` (counts references at each checkpoint, releases pages no reader can reach), `Compactor` (moving live pages out of victim segments, reclamation), `Recovery` (catalog discovery, replay of logged key changes, durable-prefix rule) and `ChangeLogCheck` (replays the log against the generations in memory; the tests use it). | ### `io.hstore.db` @@ -156,6 +156,7 @@ Notes: * Nodes are decoded once and then shared by every reader through `NodeCache`. It has 16 shards, each an open-addressing table keyed by page number plus a CLOCK (second-chance) ring, with a total byte budget of `node_cache_mb`, each node counted at an estimate of its heap size (`Node.heapBytes`). Lookups are lock-free; only admission takes a per-shard lock. Decoded nodes are immutable (`owner == null`), so sharing them needs no copying. See [pages.md](../storage/pages.md#caching-and-io-accounting). * `IoTrace` is bound with `ScopedValue.where(...)` around a query. `recordRead` throws `HStoreException.limit` once page reads plus cache hits exceed the query's page budget (`query_page_budget`). +* A tree changed since the last checkpoint starts with in-memory nodes (`Ref.Pending`). A read follows those directly and goes through `PagedNodeSource` only once it reaches a stored reference. * Historical reads use `TransactionManager.snapshotAt(generation, branch)`. It looks the generation up in the retained `history` map; see [catalog-and-generations.md](../storage/catalog-and-generations.md#history-retention-and-time-travel). ## Life of a write @@ -169,7 +170,7 @@ sequenceDiagram participant TX as Transaction participant WS as Workspace participant TM as TransactionManager - participant M as Materializer + participant CL as ChangeLog participant WAL as WriteAheadLog participant PS as PageStore participant GC as GroupCommitter (leading committer) @@ -179,38 +180,38 @@ sequenceDiagram WS->>WS: derivations update reverse, type, canonical and extension indexes W->>TX: commit() TX->>TM: commit(txn) + TM->>TM: awaitRoom: wait while the write buffer is over write_buffer_mb activate TM Note over TM: commitLock held from here TM->>TM: fast path when base roots or touched slots are unchanged,
otherwise validateReads and replay the op log on the latest roots (rebase) - TM->>M: materialize each pending root (post-order) - M->>M: encode node image (pageId 0) - M->>PS: allocate(length): next page number, 64-byte units, directory entry - M->>M: PageHeader.assign: patch pageId, reseal crc32c - M-->>TM: Stored refs, WAL Page / PageRef records, node images - TM->>WAL: append Begin, Page/PageRef*, Root*, BranchMeta*, Feed - TM->>PS: write page images + TM->>TM: page budget: count the unwritten nodes the transaction created + TM->>CL: encode(previous roots, new roots) + CL->>CL: per changed slot: key changes (TreeDiff), or a reference + CL-->>TM: Changes payload (a tree past 4,096 changes is written to pages here) + TM->>WAL: append Begin, Page/PageRef* (only for such trees), Changes, Feed + TM->>PS: write those pages, if any TM->>WAL: append Commit(generation, wallTime, nextAtom, nextTxn) - TM->>TM: head = next generation - TM->>GC: submit(Pending(next, publication)) + TM->>TM: head = next generation, roots pointing at in-memory nodes + TM->>GC: submit(Pending(next, publication, wrotePages)) deactivate TM Note over TM: commitLock released; caller waits outside the lock - GC->>PS: sync() in PAGE_REFERENCES mode (data before log) + GC->>PS: sync() in PAGE_REFERENCES mode, only if the batch wrote pages GC->>WAL: sync() - GC->>TM: publication: current = next, history.put, trimHistory + GC->>TM: publication: history.put, current = next, trimHistory GC->>F: append(CommitEvent) GC-->>TX: await() returns published generation ``` -The ordering above is what makes recovery safe. Under `PAGE_REFERENCES` the WAL carries only `(pageId, address, length, crc32c)` for each new page. The commit leader therefore makes data pages durable *before* the log records that reference them. Recovery replays committed transactions in order and stops at the first commit whose referenced pages fail verification (the *durable-prefix* rule in `Recovery.intact`). The full protocol and the crash-point matrix are in [wal-and-recovery.md](../transactions/wal-and-recovery.md). +The commit's tree nodes stay in memory. A checkpoint later writes every node reachable from a published generation to pages, syncs them and the page directory, publishes a catalog image pointing at them, and truncates the WAL up to the end of that generation's commit ([maintenance.md](../storage/maintenance.md#checkpoints)). Recovery starts from that image and replays the `Changes` records of the commits after it. When a commit does write pages, under `PAGE_REFERENCES` the WAL carries only `(pageId, address, length, crc32c)` for each, so the commit leader makes those pages durable *before* the log records that reference them, and recovery stops at the first commit whose referenced pages fail verification (the *durable-prefix* rule in `Recovery.intact`). The full protocol and the crash-point matrix are in [wal-and-recovery.md](../transactions/wal-and-recovery.md). ## Threading model | Thread | Kind | Created in | Role | |---|---|---|---| -| Caller threads | any (the server uses virtual threads) | — | Run reads without locks. Run a transaction's operations without locks, then take `commitLock` only for append (encoding, WAL append, page writes). They then block in `GroupCommitter.await` outside the lock. | -| (none; commit leader) | the committing thread | `GroupCommitter.await` | There is no group-commit thread. The first committer waiting on an unpublished commit leads: it takes every queued `Pending` as one batch and calls `makeDurable(batchSize)` once (data sync in `PAGE_REFERENCES` mode, then WAL sync, both only under `Durability.SYNC`). It then runs each publication in generation order and wakes the waiters. One fsync pair covers the whole batch (`averageGroupCommit` in the statistics). A failure poisons the pipeline, and every later commit fails until restart. | -| `hstore-maintenance` | virtual | `StorageEngine` constructor | Every 500 ms, under the `maintenance` lock (`tryLock`): checkpoint once the WAL or the pages written since the last checkpoint exceed `checkpoint_wal_mb`, compact one segment if any has fallen below `compaction_live_ratio`, then reclaim retired segments that no pinned reader can still see. | -| `hstore-references` | platform, daemon | `StorageEngine` constructor | Takes the generations that publications queue, in order, and updates the reference counts of the pages each commit added or dropped. The queue holds up to 4 096 commits; past that, publication waits for it. | +| Caller threads | any (the server uses virtual threads) | the caller | Run reads without locks. Run a transaction's operations without locks. Before committing, wait if the write buffer is full, then take `commitLock` only for append (encoding the key changes, WAL append, and page writes for the rare tree past 4,096 changes). They then block in `GroupCommitter.await` outside the lock. | +| (none; commit leader) | the committing thread | `GroupCommitter.await` | There is no group-commit thread. The first committer waiting on an unpublished commit leads: it takes every queued `Pending` as one batch and calls `makeDurable(batchSize, wrotePages)` once (data sync in `PAGE_REFERENCES` mode if a commit in the batch wrote pages, then WAL sync, both only under `Durability.SYNC`). It then runs each publication in generation order and wakes the waiters. One fsync pair covers the whole batch (`averageGroupCommit` in the statistics). A failure poisons the pipeline, and every later commit fails until restart. | +| `hstore-maintenance` | virtual | `StorageEngine` constructor | Parks for `maintenanceInterval` (default 500 ms), or until a commit that finds the write buffer full unparks it. Then, under the `maintenance` lock (`tryLock`): checkpoint once the write buffer passes half of `write_buffer_mb` or the WAL or the pages written since the last checkpoint exceed `checkpoint_wal_mb`, compact one segment if any has fallen below `compaction_live_ratio`, then reclaim retired segments that no pinned reader can still see. A background checkpoint writes the pages, counts references and publishes the catalog image on this thread. | +| `hstore-references` | platform, daemon | `StorageEngine` constructor | Marks dead the pages a transaction spilled and then gave up (abort, conflict, page budget, duplicate request). The queue holds up to 4 096 entries; past that, the caller waits. Counting itself happens at checkpoints, on the checkpointing thread. | | `feed-subscriber` | virtual, one per subscription | `ChangeFeed.subscribe` | Tails the feed segments, waiting on a condition signalled by `append` (with a 250 ms timeout). Subscribers are `SemanticPlane`, `MaterializedViews` and `Statistics`. | | Server connections | virtual, one per socket | `Server` (`Executors.newVirtualThreadPerTaskExecutor`) | Each connection runs a `Session`. | | Studio requests | virtual | `Studio` (`HttpServer.setExecutor`) | HTTP API and static assets. | @@ -219,14 +220,14 @@ Locks: * `TransactionManager.commitLock` serializes generation assignment and the WAL append order. * `StorageEngine.maintenance` serializes checkpoint and compaction. -* `TransactionManager.exclusive` holds `commitLock` and drains the group committer, so a checkpoint sees exactly the published state. -* `ReferenceCounter.pause` takes the counter's lock after counting every queued commit, so a checkpoint journals counts that match the generation it publishes. The checkpoint keeps it until the journal is applied, after it has let go of `commitLock`. +* `TransactionManager.exclusive` holds `commitLock` and drains the group committer. A checkpoint takes it only to swap the pages it wrote into the head generation; it writes pages, syncs and publishes the catalog without it. +* `ReferenceCounter.pause` takes the counter's lock after handling every queued entry, so a checkpoint counts and journals without the counter thread changing anything. The checkpoint keeps it until the journal is applied. Readers take no locks at all. ## Data directory layout -The following is real output from `hstore init` followed by loading `examples/clinical-claims.hql` and running `CHECKPOINT;` (default 16 KiB pages): +The following is real output from `hstore init` followed by loading `examples/clinical-claims.hql` and running `CHECKPOINT;` (default 16 KiB pages). It was taken with storage format 6. Format 7 writes the same files; its catalog images are smaller because they no longer hold the retained history: ```text FORMAT 55 B format=6, page-size=16384 (java.util.Properties) @@ -247,7 +248,7 @@ semantic/state 53 B persisted HNSW index state (datab | Path | Owner | Format | |---|---|---| -| `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. | +| `FORMAT` | `StorageEngine.verifyFormat` | `Properties` with `format=7` (commits logged as key changes and tree pages written at checkpoints, 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. | diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 386a1e4..6775ad3 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -257,6 +257,8 @@ not what drives HStore's footprint: * **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×). + Since storage format 7 a commit no longer writes those paths: it logs the keys it changed, and a checkpoint + writes each changed node once for all the commits since the previous one. These numbers predate that. * **Parallel incidence reads at scale 4 (0.46×).** Every probe batch opens a snapshot, which pins a generation in a shared skip-list map. At 10 threads this coordination, plus node-cache admission under churn (the hot set exceeded the 1,048,576-node cache these runs used), costs more than JE's latch-free cache hits. At scale 1 @@ -271,8 +273,8 @@ not what drives HStore's footprint: ### 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 +Storage format 6 started keeping 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 diff --git a/docs/database/evidence-and-temporal.md b/docs/database/evidence-and-temporal.md index a0fb643..f0ac64b 100644 --- a/docs/database/evidence-and-temporal.md +++ b/docs/database/evidence-and-temporal.md @@ -104,7 +104,8 @@ All validity intervals are half-open `[from, to)` in epoch milliseconds (`Validi Every commit produces a generation with a wall-clock timestamp. `AT GENERATION g` opens a snapshot of `g` on the session's branch; `AS OF t` resolves `TransactionManager.generationAsOf(t)` — the newest retained generation with `wallTime ≤ t` — and fails with `no retained generation precedes …` when `t` predates the retained window. The window -holds the last `history_limit` generations (engine option, default 64). Historical snapshots are read-only. +holds the last `history_limit` generations (engine option, default 64). It is kept in memory only, so after a restart +it starts from the last checkpointed generation and the generations recovered from the write-ahead log. Historical snapshots are read-only. ### Validity time @@ -171,7 +172,8 @@ Requirements: - `t` must be the branch's parent (`branch.parent() == target`), otherwise `INVALID_SCHEMA`; without `INTO` the parent is used. - The base generation must still be retained (`snapshotAt(base, parent)`); otherwise the merge fails with - `cannot merge branch … its fork generation … is no longer retained`. + `cannot merge branch … its fork generation … is no longer retained`. History doesn't survive a restart, so a + branch forked before the checkpoint the database reopened from can no longer be merged. - Merging a branch into itself is rejected. ```mermaid diff --git a/docs/development.md b/docs/development.md index fa5cdaa..41ef652 100644 --- a/docs/development.md +++ b/docs/development.md @@ -84,6 +84,8 @@ Test suites: | `engine/…/tree/TreeTest` | tree invariants, summaries, intersection, diff | | `engine/…/EngineTest` | transactions, rebase, branches, history, compaction | | `engine/…/CrashRecoveryTest` | crash matrix, durable-prefix rule, group-commit barriers | +| `engine/…/DeferredWritesTest` | pages written at checkpoints, the write buffer, crashes during a checkpoint while commits continue | +| `engine/…/ChangeLogTest`, `database/…/ChangeLogReplayTest` | replaying each commit's logged key changes rebuilds the trees it published | | `database/…/DatabaseTest` | reader/writer API, properties, temporal, evidence | | `database/…/query/QueryLanguageTest` | HQL end to end, planner choices | | `database/…/SecurityTest` | tenant isolation, roles, quotas, three-way merge, JSON output | diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index 0ef5d37..1b4fadd 100644 --- a/docs/operations/configuration.md +++ b/docs/operations/configuration.md @@ -90,10 +90,11 @@ max_connections = 200 |---|---|---|---| | `page_size` | `HSTORE_PAGE_SIZE` | `16384` | Page size in bytes. Must be a power of two from 1 KiB to 1 MiB. It is recorded in `/FORMAT` at creation, and opening with a different value fails with `database uses N byte pages, options request M`. | | `durability` | `HSTORE_DURABILITY` | `sync` | `sync`: a commit is acknowledged after the group-commit barrier has `fsync`ed it. `async`: acknowledged once appended, so a crash can lose a suffix of acknowledged commits but never corrupts the database. | -| `wal_mode` | `HSTORE_WAL_MODE` | `references` | `references`: the WAL logs `PageRef(pageId, address, length, crc32c)` records and data pages are synced before the WAL. `images`: the WAL logs full page images. See [WAL and recovery](../transactions/wal-and-recovery.md). | +| `wal_mode` | `HSTORE_WAL_MODE` | `references` | Applies to the pages a commit writes itself: spilled pages of bulk loads and trees that changed in more than 4,096 places. `references`: the WAL logs `PageRef(pageId, address, length, crc32c)` records and those pages are synced before the WAL. `images`: the WAL logs their full images. Ordinary commits log only the keys they changed, and pages written at checkpoints are not logged in either mode. See [WAL and recovery](../transactions/wal-and-recovery.md). | | `node_cache_mb` | `HSTORE_NODE_CACHE_MB` | `256` | Megabytes of Java heap for decoded tree nodes, each counted at an estimate of the memory it takes. | | `checkpoint_wal_mb` | `HSTORE_CHECKPOINT_WAL_MB` | `256` | WAL volume, or volume of pages written, since the last checkpoint that triggers a background checkpoint. | -| `history_limit` | `HSTORE_HISTORY_LIMIT` | `64` | Committed generations kept addressable for `AT GENERATION`, `AS OF`, `HISTORY` and the Studio time slider. | +| `write_buffer_mb` | `HSTORE_WRITE_BUFFER_MB` | `64` | Megabytes of Java heap held by committed tree nodes that no checkpoint has written yet. A background checkpoint starts once more than half of it is in use; a commit that finds it full waits for a checkpoint, for at most 30 seconds. Must be between 1 and 16,777,216. | +| `history_limit` | `HSTORE_HISTORY_LIMIT` | `64` | Committed generations kept addressable for `AT GENERATION`, `AS OF`, `HISTORY` and the Studio time slider. History is kept in memory only: after a restart it holds the checkpointed generation and the generations replayed from the WAL after it. | | `compaction_live_ratio` | `HSTORE_COMPACTION_LIVE_RATIO` | `0.5` | Sealed segments whose live bytes fall below this fraction of their allocated bytes are emptied by compaction, which moves their live pages to the active segment: one segment (the emptiest) per background pass whenever one is below the ratio, or all of them with `COMPACT`. Pages count as free once nothing refers to them and no retained generation or open reader can reach them; a checkpoint releases them. Time-travel history is preserved. Emptied segments are deleted after the next checkpoint, once no reader that started before the move is still open. Must satisfy 0 < r < 1; other values fail at startup. | | `feed_retention_generations` | `HSTORE_FEED_RETENTION_GENERATIONS` | `100000` | Committed generations kept in the [change feed](../storage/change-feed.md) for `HISTORY`, `DIFF` and subscribers. After every checkpoint, whole feed segments that lie entirely below `current − N` are deleted. *Holds* lower that floor: the semantic index's last persisted generation and the generation of each continuous materialised view. Feed segments roll at the WAL segment size (`EngineOptions.walSegmentBytes`, 64 MiB). Must be ≥ 1. | @@ -102,7 +103,7 @@ max_connections = 200 | Key | Environment | Default | Description | |---|---|---|---| | `query_page_budget` | `HSTORE_QUERY_PAGE_BUDGET` | `2000000` | Page visits allowed per query before it is aborted with `ABORTED_RESOURCE_LIMIT`. A tenant quota (`query_pages`) can lower it per tenant. | -| `transaction_page_budget` | `HSTORE_TRANSACTION_PAGE_BUDGET` | `0` | Pages one transaction may write. `0` or a negative value means unlimited. A tenant quota (`transaction_pages`) can lower it per tenant. | +| `transaction_page_budget` | `HSTORE_TRANSACTION_PAGE_BUDGET` | `0` | Pages one transaction may add: the tree nodes it created that will need a page of their own (small member trees kept inline don't count), plus the pages it spills. Most of them are written by the next checkpoint rather than by the commit. `0` or a negative value means unlimited. A tenant quota (`transaction_pages`) can lower it per tenant. | ### Semantic plane @@ -156,18 +157,25 @@ The value must be between 1 and 16,777,216 (16 TiB). Two older names are refused `cache_mb`, the name this setting had before it was released, and `cache_nodes`, a node count. Both messages name `node_cache_mb`. -**`checkpoint_wal_mb`.** A checkpoint writes the current generation's catalog image and truncates WAL segments -before the checkpoint LSN. Lower values shorten recovery, since recovery replays WAL after the last checkpoint, +**`checkpoint_wal_mb`.** A checkpoint writes the tree nodes that commits left in memory, publishes the catalog +image of the generation it wrote, and truncates WAL segments before the checkpoint LSN. Lower values shorten recovery, since recovery replays WAL after the last checkpoint, at the cost of more frequent catalog writes. Recovery time scales roughly linearly with WAL volume since the last checkpoint. The same volume of page writes also triggers a checkpoint, which is what releases dead pages during a bulk load. +**`write_buffer_mb`.** Commits keep their new tree nodes on the heap until a checkpoint writes them, and this +setting bounds that heap. A key that many commits change is written once per checkpoint, so a larger buffer means +fewer page writes for hot data and fewer checkpoints. The cost is heap, which the collector has to walk like the +node cache, and a longer replay after a crash, since recovery replays every commit since the last checkpoint. If +commits outrun checkpoints and the buffer fills, commits wait for room; one that waits 30 seconds logs a warning +and commits anyway. + **`durability` × `wal_mode`.** | Combination | Commit cost | Crash guarantee | |---|---|---| -| `sync` + `references` (default) | data `fsync` + WAL `fsync` per group-commit batch, small WAL records | Every acknowledged commit survives. | -| `sync` + `images` | WAL `fsync` per batch, WAL carries full pages (≈ page size × pages written) | Every acknowledged commit survives. Data pages need not be synced first. | +| `sync` + `references` (default) | WAL `fsync` per group-commit batch, plus a data `fsync` when a commit in the batch wrote pages | Every acknowledged commit survives. | +| `sync` + `images` | WAL `fsync` per batch; the WAL carries full images of the pages commits write themselves | Every acknowledged commit survives. Data pages need not be synced first. | | `async` + either | no `fsync` on the commit path | Recovery yields a *prefix* of the commit history. A suffix of acknowledged commits may be lost. Never a torn or mixed state. | Group commit amortises the `fsync` over concurrent committers. The Dashboard reports the observed average diff --git a/docs/operations/docker.md b/docs/operations/docker.md index f7f76b9..29b5888 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=6`, inline member trees) and the page size. Both are checked at +records the on-disk format version (`format=7`, commits logged as key changes) 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 6 (inline member trees); export and reload it`, +`database … uses storage format N; this build reads format 7 (commits logged as key changes); 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 7987f32..1319577 100644 --- a/docs/operations/logging.md +++ b/docs/operations/logging.md @@ -52,7 +52,7 @@ clean. `init` uses the configured level. | `engine` | `hstore.engine` | open, checkpoint, compaction, reclamation, shutdown (`StorageEngine`) | | `recovery` | `hstore.recovery` | crash recovery (`Recovery`) | | `references` | `hstore.references` | page reference counts (`ReferenceCounter`) | -| `txn` | `hstore.txn` | branch lifecycle (`TransactionManager`) | +| `txn` | `hstore.txn` | branch lifecycle and the write buffer (`TransactionManager`) | | `commit` | `hstore.commit` | group-commit pipeline (`GroupCommitter`) | | `feed` | `hstore.feed` | change-feed retention and subscribers (`ChangeFeed`) | | `wal` | `hstore.wal` | write-ahead log (`WriteAheadLog`; DEBUG only) | @@ -72,9 +72,8 @@ This is every message the server emits. Placeholders are in `{braces}`. | LOG | main | `configuration: {key}={value} ({source}), …` | Settings that differ from their default, with source `file`, `environment` or `command line`. | | LOG | recovery | `recovered generation {g}: replayed {n} commits from lsn {lsn}, discarded {t} unfinished transactions and {c} non-durable commits` | Always logged at open. `{lsn}` is the checkpoint LSN replay started from. | | WARNING | recovery | `durable prefix ends before generation {g}: transaction {txn} references pages that never reached storage` | In `references` WAL mode, a committed transaction whose `PageRef` pages fail verification ends the recovered prefix. Later commits are discarded. Seen only after a crash with `durability = async` or a storage fault. | -| LOG | references | `released {n} pages that no committed generation refers to` | Logged at open when pages written by transactions that never committed, for example ones cut short by a crash, were found and released. | -| WARNING | references | `reference counts could not follow the log, rebuilding them` | Counting the commits replayed at open failed. The counts are rebuilt from the retained generations. | -| LOG | references | `rebuilt reference counts in {ms} ms` | The counts were marked for rebuilding, or could not follow the log, and were recounted at open. | +| LOG | references | `{n} pages are not counted yet; the first checkpoint counts them or releases them` | Logged at open when pages were written after the last checkpoint: spilled by a bulk load, written by a commit or by a checkpoint that never finished, or written by a transaction that never committed. The checkpoint at the end of the open keeps the ones the recovered trees use and marks the rest dead. | +| LOG | references | `rebuilt reference counts in {ms} ms` | The counts were marked for rebuilding and were recounted at open from the checkpointed generation. | | WARNING | references | `{n} referenced pages have no address` | Pages that are counted as in use have no directory entry. Run `hstore check`. | | LOG | engine | `database at {dir} is ready: generation {g}, {n} data segments, page size {bytes}, {durability} durability, {walMode} wal` | Engine open complete. | | LOG | semantic | `loaded semantic index at generation {g} ({n} models)` | HNSW indexes restored from `/semantic`. | @@ -100,6 +99,7 @@ This is every message the server emits. Placeholders are in `{braces}`. | LOG | engine | `reclaimed retired segments {list}` | | WARNING | hstore | `background maintenance failed` (with DETAIL) | | ERROR | references | `reference counting stopped; the counts will be rebuilt when the database next opens` (with DETAIL). No more pages are released until a restart, so space stops being reclaimed but nothing is lost. | +| WARNING | txn | `the write buffer stayed full for 30 s; committing anyway`: commits produced unwritten tree nodes faster than checkpoints wrote them, past `write_buffer_mb`, and this commit stopped waiting. | | LOG | txn | `created branch {name} (#{id}) from generation {g}` | | LOG | txn | `branch #{id} is now {MERGED or DROPPED}` | | ERROR | commit | `commit pipeline failed; the engine must be restarted` (with DETAIL). The committer leading a batch could not make it durable or publish it. All waiting and future committers fail. | @@ -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 6 (inline member trees); export and reload it` | `FORMAT` was written by an incompatible build. | +| `database uses storage format N; this build reads format 7 (commits logged as key changes); 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 3812edb..ea8d3a7 100644 --- a/docs/storage/catalog-and-generations.md +++ b/docs/storage/catalog-and-generations.md @@ -72,6 +72,8 @@ The `TransactionManager` keeps two pointers: * **`head`**: the latest generation *appended* to the WAL. It is updated under `commitLock` inside `append`. The next committer builds on it, so commits pipeline without waiting for fsync. * **`current`**: the latest generation *published*, meaning durable when `Durability.SYNC`. It is set by the group committer's publication callback, strictly in generation order. New snapshots and transactions pin `current`, so readers never observe a commit that could be lost in a crash. +A published generation is durable through its WAL records, not through pages. The trees a commit changed are in memory (`Ref.Pending` nodes on top of the last checkpoint's pages) until a checkpoint writes them; the checkpoint then swaps the stored references into `head` and `current` ([maintenance.md](maintenance.md#checkpoints)). + ```mermaid sequenceDiagram participant T1 as commit g+1 @@ -82,8 +84,8 @@ sequenceDiagram T2->>TM: append (head = g+2, built on g+1) TM->>GC: submit g+1, g+2 GC->>GC: one data sync + one WAL sync for the batch - GC->>TM: publish g+1: current = g+1, history.put, feed.append - GC->>TM: publish g+2: current = g+2, history.put, feed.append + GC->>TM: publish g+1: history.put, current = g+1, feed.append + GC->>TM: publish g+2: history.put, current = g+2, feed.append GC-->>T1: await returns g+1 GC-->>T2: await returns g+2 ``` @@ -100,9 +102,9 @@ Published generations are kept in `TransactionManager.history` (a `ConcurrentSki * `snapshotAt(generation, branch)` pins the generation and opens a snapshot over its root vector. A generation that has been trimmed fails with `generation N is no longer retained`. * `generationAsOf(wallTime)` returns the newest retained generation with `wallTime <= t`. -Time travel needs no undo log. Copy-on-write never overwrites a page, so an old generation's root vector still points at valid pages, and reading the past is exactly as fast as reading the present. Compaction moves the pages a retained generation still needs and keeps their page numbers, so old root vectors stay valid after their segments are deleted ([maintenance.md](maintenance.md#reclaim)). Compaction therefore never shortens the time-travel window. +Time travel needs no undo log. Copy-on-write never overwrites a page or changes a published node, so an old generation's root vector still points at valid pages, or at in-memory nodes that the generation keeps alive until a checkpoint has written them, and reading the past is exactly as fast as reading the present. Compaction moves the pages a retained generation still needs and keeps their page numbers, so old root vectors stay valid after their segments are deleted ([maintenance.md](maintenance.md#reclaim)). Compaction therefore never shortens the time-travel window. -The retained history is also persisted. `Checkpointer` writes up to `historyLimit` past generations into the catalog image, and `Recovery` appends every replayed generation, so time travel survives restarts. +The retained history is not persisted. A checkpoint writes the pages of one generation only, so the catalog image holds that generation and an empty history. After a restart, `Recovery` rebuilds the commits logged since that checkpoint, and the history holds the checkpointed generation and the generations replayed after it. `AT GENERATION` and `AS OF` can't reach anything older, and neither can a branch merge that needs an older base generation (see [Branches](#branches)). Older changes are still in the change feed, within `feed_retention_generations`. HQL: @@ -119,9 +121,9 @@ Branches are named, independently writable lines of generations that share pages | Operation | `TransactionManager` method | Effect | |---|---|---| -| create | `createBranch(name, parentId)` | `id = max(existing ids) + 1`. Copies the parent's `RootVector` *by reference*, so creating a branch is O(1) with no page writes. Records `baseGeneration = head.id` and appends a `BranchMeta` WAL record. Active names must be unique. | +| create | `createBranch(name, parentId)` | `id = max(existing ids) + 1`. Copies the parent's `RootVector` *by reference*, so creating a branch is O(1) with no page writes. Records `baseGeneration = head.id` and appends a `BranchMeta` WAL record but no `Changes` record; replay gives a new branch its parent's roots. Active names must be unique. | | write | `begin(TxnOptions.onBranch(id))` | Commits change only that branch's entry in the next generation. | -| merge | `markMerged(id)` → `closeBranch(id, MERGED)` | Sets the state to `MERGED` **and empties its roots**, so its pages are no longer reachable from the current generation. The actual three-way merge of contents happens in `db.temporal.BranchMerge` before this call. | +| merge | `markMerged(id)` → `closeBranch(id, MERGED)` | Sets the state to `MERGED` **and empties its roots**, so its pages are no longer reachable from the current generation. The actual three-way merge of contents happens in `db.temporal.BranchMerge` before this call. It reads the branch's `baseGeneration` as the common ancestor, so that generation must still be retained; otherwise the merge fails with `cannot merge branch b: its fork generation g is no longer retained`. After a restart that is the case for any branch forked before the checkpoint the database reopened from. | | drop | `dropBranch(id)` → `closeBranch(id, DROPPED)` | The same, with state `DROPPED`. `Generation.branch` rejects a dropped id. | `main` (id 0) cannot be closed. `StorageEngine.branches()` lists only `ACTIVE` branches. Because closed branches carry empty root vectors, they keep no pages alive. @@ -142,7 +144,7 @@ MERGE BRANCH what_if INTO main ON CONFLICT FAIL; Generation := id | wallTime | txnId | nextAtom | nextTxn | branchCount | Branch* Branch := id | name | parent | baseGeneration | createdAt | u8 state | rootCount | (slot | Ref)* Ref := u8 0 (empty) | u8 1 | i64le pageId | varint units | Summary (slot roots are never inline) -CatalogImage:= Generation(current) | historyCount | Generation* | segmentCount +CatalogImage:= Generation(current) | historyCount | Generation* | segmentCount (historyCount is 0 since format 7) | (segmentId | u8 state | pages | retiredAt)* | checkpointLsn | feedEnd ``` diff --git a/docs/storage/maintenance.md b/docs/storage/maintenance.md index 54d7642..7b306d9 100644 --- a/docs/storage/maintenance.md +++ b/docs/storage/maintenance.md @@ -2,22 +2,25 @@ Copy-on-write storage never overwrites data, so three background concerns replace in-place update: -* **Checkpoints** bound recovery time and WAL size. +* **Checkpoints** write the trees commits left in memory to pages, and bound recovery time, WAL size and the + memory those trees take. * **Reference counting and compaction** find the node images nothing can reach any more and return their space to the file system. * **Verification** proves that what is on disk is internally consistent. -Sources are under `engine/src/main/java/io/hstore/engine/maintenance/`, plus `StorageEngine.java`, `page/PageStore.java`, `page/ReferenceCounts.java`, `tree/TreeWalker.java` and `tree/TreeVerifier.java`. +Sources are under `engine/src/main/java/io/hstore/engine/maintenance/`, plus `StorageEngine.java`, `txn/TransactionManager.java` (the write buffer), `page/PageStore.java`, `page/ReferenceCounts.java`, `tree/Materializer.java`, `tree/TreeWalker.java` and `tree/TreeVerifier.java`. ## Who runs what | Trigger | Action | Code | |---|---|---| | Engine open, after recovery | checkpoint, then reclaim | `StorageEngine` constructor | -| `hstore-maintenance` virtual thread, every 500 ms (skipped when the `maintenance` lock is busy) | checkpoint if more than `checkpointWalBytes` of WAL or of pages were written since the last one, or more than 1 048 576 counts changed; compact one segment if any is below `compaction_live_ratio` (followed by a checkpoint); then reclaim | `StorageEngine.maintain` | +| `hstore-maintenance` virtual thread, every `maintenanceInterval` (default 500 ms) or when a commit wakes it because the write buffer is full (skipped when the `maintenance` lock is busy) | checkpoint if the write buffer holds more than half of `write_buffer_mb`, more than `checkpointWalBytes` of WAL or of pages were written since the last one, or more than 1 048 576 counts changed; compact one segment if any is below `compaction_live_ratio`, sealing the active one first if it is at least a quarter full and that far below (followed by a checkpoint); then reclaim | `StorageEngine.maintain` | | `CHECKPOINT;` (HQL), `StorageEngine.checkpoint()` | checkpoint | `timedCheckpoint` | | `COMPACT;` (HQL), `StorageEngine.compact()` | checkpoint, compact, checkpoint, reclaim | `StorageEngine.compact` | -| `hstore-references` platform thread, after every published commit | update reference counts | `ReferenceCounter` | +| Every checkpoint | count references between the previous checkpointed generation and the new one | `ReferenceCounter.checkpointed` | +| `hstore-references` platform thread, when a transaction gives up pages it spilled | mark those pages dead | `ReferenceCounter` | +| `StorageEngine.verifyReferences()`, `hstore check` | checkpoint, then compare the counts | `verifyReferences` | | `StorageEngine.close()` | final checkpoint, then shutdown | `close` | Checkpoint and compaction are serialized by `StorageEngine.maintenance`, a `ReentrantLock`. Background compaction plays the role of PostgreSQL's autovacuum. It runs whenever a sealed segment's live bytes fall below `compaction_live_ratio` of its allocated bytes, which the reference counts keep up to date, and it empties one segment per pass, the emptiest. `COMPACT;` empties every such segment on demand. @@ -30,31 +33,43 @@ Every checkpoint is logged, and then the checkpoint listeners run. The database ## Checkpoints -`Checkpointer.checkpoint()` (`maintenance/Checkpointer.java`) first syncs the segment files and forces the page directory without holding anything, so that most of the data written since the last checkpoint is on disk before commits have to wait. The rest runs inside `TransactionManager.exclusive(...)`. That holds the commit lock **and drains the group committer**, so every appended commit is also published and nothing is in flight. It then lets the reference counter catch up with every published commit and pauses it (`ReferenceCounter.pause`). Then: +An ordinary commit doesn't write tree pages. It logs the keys it changed ([wal-and-recovery.md](../transactions/wal-and-recovery.md#the-changes-payload)) and publishes a generation whose roots point at in-memory nodes. The checkpoint is what writes those nodes to pages, once for all the commits since the previous checkpoint. Only a tree that changed in more than 4,096 places in one commit, such as one a bulk load built, is written by the commit itself. -1. It reads `lsn = wal.end()`. -2. It releases the pages that died before the oldest generation anyone can still read (see [Releasing pages](#releasing-pages)). -3. `pages.checkpoint()` fsyncs every dirty segment file and forces the page directory. -4. `pages.journal(lsn)` writes every count changed since the last checkpoint, and every directory entry released, to `pages.ref.journal`, and fsyncs it. -5. `feed.sync()` fsyncs the active feed segment. -6. It captures `current` and the retained history: all retained generations except current, newest first, at most `historyLimit`. -7. `catalog.publish(new CatalogImage(current, history, pages.segments(), lsn, feed.size()))` writes the image durably and atomically ([catalog-and-generations.md](catalog-and-generations.md#catalog-images-checkpoints)). The image includes every segment's `(id, state, pages, units, retiredAt)`. -8. It appends a `WalRecord.Checkpoint(0, generation, lsn)` and calls `wal.sync()`. -9. `wal.truncateBefore(lsn)` deletes WAL segment files that end before the checkpoint LSN. They are no longer needed, because the catalog image now covers them. -10. The commit lock is released here, and commits carry on. `pages.applyJournal()` writes the journaled counts into `pages.ref` and clears the released directory entries, forces both files, and empties the journal. The counter stays paused until this is done, so the counts it writes next start from the applied state. +`Checkpointer.checkpoint()` (`maintenance/Checkpointer.java`) works on `g`, the published current generation when it starts. Commits keep running during the whole checkpoint except one short step (step 9). -If a step after the catalog is published fails and the engine keeps running, the journal on disk still belongs to the published catalog. The next checkpoint applies that journal before it writes its own, so the file never falls more than one checkpoint behind the catalog. If publishing the catalog itself fails, it isn't known whether the new catalog reached the disk, so `pages.ref` is marked for rebuilding and the next open recounts. -11. It remembers `lastLsn = lsn` and resumes the counter. +1. It walks every branch's roots in `g` with a `Materializer` and writes each pending node: allocate, `pages.write`, admit the frozen node to the node cache. Each in-memory node is stamped with its stored reference and the checkpoint's `Epoch`. A node stamped by the last published epoch already has a page and is not written again ([persistent-tree.md](persistent-tree.md#materialization)). +2. `pages.checkpoint()` fsyncs every dirty segment file and forces the page directory. A failure up to here hands the pages written so far to the reference counter, which marks them dead. +3. It pauses the reference counter (`ReferenceCounter.pause`) and applies the previous checkpoint's journal if that was never done. +4. It takes `lsn`, the WAL position just after `g`'s `Commit` record (`TransactionManager.commitEnd`). Commits published after `g` stay above it. +5. It counts references from the previously checkpointed generation to the one it just wrote (see [Reference counts](#reference-counts)) and releases the pages that died before the oldest generation anyone can still read (see [Releasing pages](#releasing-pages)). +6. `pages.checkpoint()` again, then `pages.journal(lsn)` writes every count changed since the last checkpoint, and every directory entry released, to `pages.ref.journal`, and fsyncs it. +7. `feed.sync()` fsyncs the active feed segment. +8. `catalog.publish(new CatalogImage(g, List.of(), pages.segments(), lsn, feed.size()))` writes the image durably and atomically ([catalog-and-generations.md](catalog-and-generations.md#catalog-images-checkpoints)). The image holds the written `g` and every segment's `(id, state, pages, units, retiredAt)`. Retained history is not written. +9. `TransactionManager.checkpointed` publishes the epoch. Then, under the commit lock and with the group committer drained, it swaps the written pages into the head generation (`Materializer.rebound`), so later commits build on stored references instead of in-memory nodes. It recounts the write buffer and wakes commits that were waiting for it. +10. It appends a `WalRecord.Checkpoint(0, g, lsn)`, calls `wal.sync()`, and `wal.truncateBefore(lsn)` deletes WAL segment files that end before the checkpoint LSN. The catalog image now covers them. +11. `pages.applyJournal()` writes the journaled counts into `pages.ref` and clears the released directory entries, forces both files, and empties the journal. It remembers `lastLsn = lsn` and resumes the counter. -**Trigger threshold.** `walSinceCheckpoint() = wal.end() − lastLsn` is compared with `checkpoint_wal_mb` (default 256 MiB, `EngineOptions.checkpointWalBytes`). In the default `PAGE_REFERENCES` WAL mode a commit logs about 47 bytes per new node image instead of the image itself. The threshold therefore corresponds to far more committed work than in `PAGE_IMAGES` mode, and replay after a crash stays short. +If a step after the catalog is published fails and the engine keeps running, the journal on disk still belongs to the published catalog. The next checkpoint applies that journal before it writes its own, so the file never falls more than one checkpoint behind the catalog. If publishing the catalog itself fails, it isn't known whether the new catalog reached the disk, so `pages.ref` is marked for rebuilding and the next open recounts. The full ordering, and what a crash at each step leaves behind, is in [wal-and-recovery.md](../transactions/wal-and-recovery.md#9-checkpoints). -**What recovery gains.** Recovery loads the newest valid catalog image and replays only WAL records after `checkpointLsn` ([wal-and-recovery.md](../transactions/wal-and-recovery.md)). The image's persisted history makes time travel survive restarts. Its segment table restores the allocator position (`units`) and the compaction state machine. +**Trigger threshold.** `walSinceCheckpoint() = wal.end() − lastLsn` is compared with `checkpoint_wal_mb` (default 256 MiB, `EngineOptions.checkpointWalBytes`), and so is the volume of pages written since the last checkpoint. A commit logs its changed keys rather than its pages, so the threshold corresponds to a lot of committed work, and replay after a crash stays short. + +**What recovery gains.** Recovery loads the newest valid catalog image and replays only the commits logged after `checkpointLsn` ([wal-and-recovery.md](../transactions/wal-and-recovery.md)). Its segment table restores the allocator position (`units`) and the compaction state machine. History is not in the image, so time travel after a restart reaches only the generations recovery rebuilt ([catalog-and-generations.md](catalog-and-generations.md#history-retention-and-time-travel)). + +### The write buffer + +Until a checkpoint writes them, the nodes commits created live on the heap. `TransactionManager.recountUnwritten` measures them: the heap estimate of every pending node reachable from the head generation that has not been written by a published checkpoint or by its own commit, nested trees included. `write_buffer_mb` (default 64, `EngineOptions.writeBufferBytes`) bounds it: + +* The maintenance thread checks it on every pass and checkpoints once it passes half the limit. +* A commit that finds it over the limit, before taking the commit lock, wakes the maintenance thread and waits for a checkpoint to make room. It checks again every 50 ms. After 30 seconds it logs `the write buffer stayed full for 30 s; committing anyway` and commits. +* Each commit adds the heap of the nodes it created to a running total, so the check is cheap. The total is recounted exactly on every maintenance pass, after every checkpoint, and when a commit finds it over the limit. + +A larger buffer means fewer checkpoints and fewer page writes for keys that change often, at the cost of heap and of a longer replay after a crash, since every commit since the last checkpoint is replayed. ## Space reclamation ### Why space needs reclaiming -Each commit writes fresh node images for every path it changes and references all unchanged subtrees from the previous generation ([persistent-tree.md](persistent-tree.md#update-algorithm)). The images a commit replaced stay on disk. They are still *reachable* while a retained generation references them, which is what makes time travel possible. Once the generations that reference them leave history, they are garbage. +Each checkpoint writes fresh node images for every path changed since the previous one and references all unchanged subtrees from the previous checkpoint's pages ([persistent-tree.md](persistent-tree.md#update-algorithm)). The images it replaced stay on disk. They are still *reachable* while a retained generation references them, which is what makes time travel possible. Once the generations that reference them leave history, they are garbage. Segments are append-only extents ([pages.md](pages.md#segment-files)), so garbage is never reused piecemeal. It is reclaimed one whole segment at a time: @@ -74,59 +89,64 @@ generation and branch, still points at it. ```mermaid stateDiagram-v2 [*] --> ACTIVE: rollSegment() - ACTIVE --> SEALED: next segment rolled (capacity reached) + ACTIVE --> SEALED: next segment rolled (capacity reached), or sealed by compaction (mostly dead) SEALED --> COMPACTING: chosen as victim (live < bytes × ratio) COMPACTING --> SEALED: reopened after a crash mid-compaction COMPACTING --> RETIRED: live pages moved, retiredAt = current generation RETIRED --> [*]: reclaim(): file deleted after a checkpoint, once oldestPinned > retiredAt ``` -`SegmentState.canTransitionTo` allows only the next state, plus `COMPACTING → SEALED`. `SegmentInfo.withState` throws on anything else. `PageStore.open` turns a `COMPACTING` segment back into `SEALED`, so a compaction cut short by a crash leaves the segment a candidate for the next pass. Only one segment is `ACTIVE` at a time: the one `PageStore.allocate` appends to. It is never chosen as a victim. +`SegmentState.canTransitionTo` allows only the next state, plus `COMPACTING → SEALED`. `SegmentInfo.withState` throws on anything else. `PageStore.open` turns a `COMPACTING` segment back into `SEALED`, so a compaction cut short by a crash leaves the segment a candidate for the next pass. Only one segment is `ACTIVE` at a time: the one `PageStore.allocate` appends to. It is never chosen as a victim, but a compaction pass can seal it first (see [Compaction](#compaction)), and the next allocation then rolls to a new segment. ### Reference counts Every page number has a count in `data/segments/pages.ref` ([pages.md](pages.md#reference-counts)): the number of -places that point at it in the current state of the database. A place is a branch's root for a slot, a child +places that point at it in the last checkpointed generation. A place is a branch's root for a slot, a child 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 +so the counter doesn't even decode it. When an edge grows past the inline limit, the next checkpoint 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 -vectors and then: +**At each checkpoint.** Counting happens only between two generations whose trees are fully written: the one +the previous checkpoint wrote and the one the current checkpoint just wrote. `ReferenceCounter.checkpointed` runs +on the checkpointing thread while counting is paused, before the journal is written. Generations published in +between are never counted. Their new nodes are in memory and have no pages, and every page they reach was either +counted by the previous checkpoint, which keeps that count until this one, or is provisional (below). For each branch whose roots changed the counter +compares the two root vectors and then: -1. Adds one to every new root. A page whose count goes up from zero is new, or was written by a bulk load, so the - pages it points at get one more as well, and so on down. +1. Adds one to every new root. A page whose count goes up from zero is new: written by this checkpoint, spilled by a + bulk load, or written by a commit that logged its tree by reference. The pages it points at get one more as + well, and so on down. 2. Takes one from every old root. A page whose count reaches zero is dead, so the pages it pointed at lose one too. -Step 1 runs to the end before step 2 starts, and its additions are collected in a small map first. When a commit -replaces a node, the new copy adds one to each child and the old one takes it away again, so those children cancel -out and their counts are never touched. What the counter really writes is the few pages that a commit added or -removed. Reading a node's references needs the node, which almost always comes from the node cache since the commit -just wrote it or read it. A leaf below a branch node is never read when its tree's values hold no references, -since the branch already says its children are leaves. Other leaves are read only as far as the header when they -aren't cached: a header flag (`PageHeader.REFERS_TO_PAGES`) says whether any value in the leaf points at another -page, and most leaves, such as catalog leaves holding nodes and index leaves with inline postings, don't. That keeps -counting the commits replayed after a crash about as cheap as replaying them. - -A page that dies is marked with the generation it died in. Older generations can still reach it, so its bytes stay -where they are until no one can read those generations (see below). - -**Pages nothing commits.** `Transaction.load` can write pages before the transaction commits. If the transaction -aborts, fails with a conflict, or turns out to repeat a request that already committed, those pages are marked dead -straight away. If it commits but replaced some of them -before committing, the ones the new trees don't reach are marked dead after the commit is counted. Pages written by -a transaction that a crash cut short are found on the next open (see below). +Step 1 runs to the end before step 2 starts, and its additions are collected in a small map first. When a node is +replaced, the new copy adds one to each child and the old one takes it away again, so those children cancel out +and their counts are never touched. What the counter really writes is the few pages that changed between the two +checkpoints, however many commits there were in between. Reading a node's references needs the node, which almost +always comes from the node cache since the checkpoint just wrote it or a commit read it. A leaf below a branch node +is never read when its tree's values hold no references, since the branch already says its children are leaves. +Other leaves are read only as far as the header when they aren't cached: a header flag +(`PageHeader.REFERS_TO_PAGES`) says whether any value in the leaf points at another page, and most leaves, such as +catalog leaves holding nodes and index leaves with inline postings, don't. + +A page that dies is marked with the generation of the checkpoint that found it dead. Older generations can still +reach it, so its bytes stay where they are until no one can read those generations (see below). + +**Provisional pages.** Some pages exist before any checkpoint has counted them: pages `Transaction.load` spills +before its transaction commits, and pages a commit writes for a tree it logs by reference. When the commit is +published they are recorded as provisional for its generation (`ReferenceTracker.provisional`). The checkpoint that +covers that generation counts the ones its trees reach and marks the rest dead. If the transaction aborts, fails +with a conflict or a page budget error, or turns out to repeat a request that already committed, its spilled pages +are handed to the `hstore-references` thread instead, which marks them dead straight away. Pages written by a +transaction that a crash cut short are found on the next open (see below). **Between checkpoints** the changes live in a map in memory (`ReferenceCounts.changed`) and `pages.ref` itself is -not touched. A checkpoint journals them and then applies them (steps 4 and 10 above), so a crash at any point leaves +not touched. A checkpoint journals them and then applies them (steps 6 and 11 above), so a crash at any point leaves the file as of one checkpoint or the next, never a mix. The background thread checkpoints early when more than 1 048 576 counts have changed, which keeps that map to a few tens of megabytes. It also checkpoints after every `checkpoint_wal_mb` of pages written, because a bulk load writes many pages for little WAL and its dead pages would @@ -134,21 +154,24 @@ otherwise wait a long time to be released. **On open**, `PageStore.open` applies `pages.ref.journal` if it was written by the checkpoint the catalog points at and ignores it otherwise. A `pages.ref` that is missing while the directory already has pages is marked for -rebuilding. `ReferenceCounter.recover` then counts the generations recovery replayed from the WAL, in -order, exactly as it would have after each commit. Last, one pass over `pages.dir` and `pages.ref`: - -* releases every page that has an address but a count of zero and isn't marked dead. No committed generation - points at it; it was written by a transaction that never committed; +rebuilding. The counts then describe the checkpointed generation; the generations recovery replays from the WAL are +in memory and are counted by the checkpoint at the end of the open. Last, `ReferenceCounter.recover` makes one pass +over `pages.dir` and `pages.ref` (`PageStore.scan`), which: + +* collects every page that has an address but a count of zero and isn't marked dead. Such a page was written after + the last checkpoint: spilled, written by a commit, written by a checkpoint that never published, or written by a + transaction that never committed. Its entry is kept, and the pages are handed to the first checkpoint as + provisional, which counts the ones it reaches and marks the others dead; * adds up the live bytes of each segment; * collects the dead pages that are still waiting, in order of the generation they died in. **If counting fails**, for example on a page that can't be read, the counter stops, logs an error and marks `pages.ref` for rebuilding. It releases nothing more until the engine restarts, so the worst case is space that -isn't reclaimed. The next open sees the mark and recounts from the retained generations: every page reachable from -the current state gets its exact count, and every page only older generations reach is marked dead as of the current -generation. That walk reads every node of every retained tree, so it takes as long as `hstore check`. The rebuild -writes `pages.ref` directly but leaves the mark in place until the checkpoint at the end of the open has published -its catalog, because until then the catalog still describes an older generation. A crash in between rebuilds again. +isn't reclaimed. The next open sees the mark and recounts from the checkpointed generation and any history its +catalog image holds: every page reachable from the checkpointed generation gets its exact count, and every page +only older generations reach is marked dead as of the checkpointed generation. That walk reads every node of every +tree it covers, so it takes as long as `hstore check`. The rebuild writes `pages.ref` directly but leaves the mark +in place until the checkpoint at the end of the open has published its catalog. A crash in between rebuilds again. A thread error such as running out of memory is handled the same way as a failed count. ### Releasing pages @@ -175,13 +198,14 @@ moved if their segment is compacted. `Compactor.compact(liveThreshold)` (default threshold `compaction_live_ratio = 0.5`): -1. **Choose victims.** A victim is a `SEALED` segment, not the active one, with `live bytes < allocated bytes × +1. **Seal a mostly dead active segment.** If the active segment's live bytes are below `allocated bytes × threshold`, the pass seals it (`PageStore.sealActive`) so it can be chosen in the next step. `COMPACT` does this at any size; the background pass only once the segment holds at least a quarter of a segment's capacity. A database whose writes never fill the active segment would otherwise never get that dead space back. +2. **Choose victims.** A victim is a `SEALED` segment, not the active one, with `live bytes < allocated bytes × threshold`, so the ratio is *live bytes / bytes written*. Every page allocated in it must also have been written - (`PageStore.settled`); a commit allocates its pages before it logs them and writes them right after, so a - segment can be sealed while a few of its pages are still on their way. Candidates are ordered from emptiest to + (`PageStore.settled`); a commit or a spill allocates its pages before it logs them and writes them right after, + so a segment can be sealed while a few of its pages are still on their way. Candidates are ordered from emptiest to fullest. The background pass takes only the first one (`compact(threshold, 1)`); `COMPACT` takes all of them. -2. Mark the victims `COMPACTING`. -3. **Move.** `PageStore.evacuate` reads through the victim one 64-byte unit at a time. Where a page header starts and +3. Mark the victims `COMPACTING`. +4. **Move.** `PageStore.evacuate` reads through the victim one 64-byte unit at a time. Where a page header starts and the directory entry for that header's page number points at exactly this spot, the page is live: it is checked with `PageHeader.verify` and its bytes are written to the active segment. Anything else, such as a released page or the old copy of a page that has already moved, is skipped. The header holds the page number, not @@ -190,7 +214,7 @@ moved if their segment is compacted. that aren't there yet. Afterwards the victim's live total has to be zero. If it isn't, some page the directory points at couldn't be read where it should be, and the victim goes back to `SEALED` instead of being retired, so its file is kept. -4. Mark the victims `RETIRED` with `retiredAt` set to the current generation. +5. Mark the victims `RETIRED` with `retiredAt` set to the current generation. The work is proportional to the size of the victims, not of the database, and the only memory it needs is a short list of the pages moved in the current 4 MiB. `StorageEngine.compact()` checkpoints before compacting, so pages @@ -223,12 +247,14 @@ victim's scan finds and moves them like any other page, and the transaction comm Retained history no longer holds a segment back. Old generations reach moved pages through the directory like everything else, so `AT GENERATION` and `AS OF` keep working after the victims are gone. -**Why it must wait for readers.** A reader looks up a page's address and then reads the page. One that started before the move may have looked up an old address and not read it yet. Every reader and writer pins its generation, and anyone pinned at `retiredAt` or earlier may have started before the moves finished. Deleting the file early would turn that reader's next read into `segment does not exist`. Pins are reference counts per generation (`TransactionManager.pins`). Every `Snapshot.close()` and transaction end decrements them, and the maintenance thread retries `reclaim` every 500 ms. A retired segment therefore disappears within half a second of its last reader closing. +**Why it must wait for readers.** A reader looks up a page's address and then reads the page. One that started before the move may have looked up an old address and not read it yet. Every reader and writer pins its generation, and anyone pinned at `retiredAt` or earlier may have started before the moves finished. Deleting the file early would turn that reader's next read into `segment does not exist`. Pins are reference counts per generation (`TransactionManager.pins`). Every `Snapshot.close()` and transaction end decrements them, and the maintenance thread retries `reclaim` on every pass (`maintenanceInterval`, 500 ms by default). A retired segment therefore disappears within one interval of its last reader closing. ### History retention and pins `TransactionManager.trimHistory` keeps at most `history_limit` generations (default 64). The current generation and every pinned generation are always kept. An unpinned old generation is dropped even when a newer pinned one exists, so one long-running snapshot keeps its own generation alive without holding back trimming of the generations before it. +History is kept in memory only. The catalog image no longer stores it, so after a restart the history holds the checkpointed generation and the generations recovery replayed after it. + History and pins are also what hold dead pages back. A page waits until every generation older than the one it died in has been trimmed and unpinned, so a long-running snapshot delays the release of everything that died after it began, and a larger `history_limit` keeps more dead pages on disk. ## The change feed on open @@ -251,10 +277,17 @@ History and pins are also what hold dead pages back. A page waits until every ge 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 -every page an old generation can reach still has an address and isn't due to be released while that generation is -kept. +It then calls `StorageEngine.verifyReferences()`. That runs a checkpoint first, so that the counts describe a fully +written generation, then counts the references in the last checkpointed generation again from scratch and compares +every entry of `pages.ref` with the result. It also walks the retained history, following stored references through +any in-memory nodes, and checks that every page an old generation can reach still has an address, is counted or +provisional, and isn't due to be released while that generation is kept. + +`StorageEngine.verifyChangeLog()` (`maintenance/ChangeLogCheck.java`) checks the log instead of the pages. It reads +the WAL from the start, and for every commit whose generation and predecessor are both still in memory it applies +the commit's `Changes` record to the predecessor's roots and compares the result with the generation the commit +published, entry by entry and through nested trees. The engine and database test suites run it after random +workloads; `hstore check` does not. Output from a database loaded with `examples/clinical-claims.hql` (16 KiB maximum node size, packed extents): @@ -276,6 +309,7 @@ A failure names the page, for example `HStoreException.corrupt(pageId, "checksum | Setting | Default | Effect | |---|---|---| | `checkpoint_wal_mb` | 256 | WAL or page volume since the last checkpoint that triggers a background checkpoint | +| `write_buffer_mb` | 64 | Heap held by nodes no checkpoint has written yet. A background checkpoint starts above half of it; commits wait while it is over the limit | | `history_limit` | 64 | Generations kept for time travel (more while pinned) | | `compaction_live_ratio` | 0.5 | A sealed segment is compacted when fewer than this fraction of its images are live | | `page_size` | 16384 | Maximum node image size; fixed at `init` | diff --git a/docs/storage/pages.md b/docs/storage/pages.md index ff9b2bb..0aa2dc1 100644 --- a/docs/storage/pages.md +++ b/docs/storage/pages.md @@ -38,7 +38,7 @@ Sizes are computed exactly before writing (`varLongSize`, `signedVarLongSize`, ` | 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 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`. | +| 16 | 8 | `creationEpoch` | Id of the generation the image was written for: the checkpointed generation for a page a checkpoint writes, the new generation for a page a commit writes. Spilled pages use `head + 1`. | | 24 | 4 | `payloadLength` | Payload bytes after the header. Readers use it to size the second read. | | 28 | 4 | `slotCount` | Number of entries (leaf) or children (branch) | | 32 | 8 | `logicalCount` | `Summary.count` of the subtree | @@ -86,7 +86,7 @@ INTERNAL: Ref × slotCount 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. +**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`. The tree changes in a WAL `Changes` record are read with their schema as well, so a nested tree change can carry an inline tree. Everywhere else (branch children, catalog images, 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: @@ -170,8 +170,8 @@ no image. The file is memory-mapped in 8 MiB chunks, and a new chunk is mapped w lookup is a load from the OS page cache and the directory takes no Java heap. A million pages need 8 MB of it. Trees, nested references, `NodeCache` and `Ref` use numbers and never see an address. Only `PageStore` resolves -them, and the WAL logs each new page's address next to its number so that recovery can put the directory entry -back. That is what lets compaction move an image by copying its bytes and changing one directory entry, without +them, and the WAL logs the address of each page a commit or a spill writes next to its number so that recovery +can put the directory entry back. That is what lets compaction move an image by copying its bytes and changing one directory entry, without rewriting the nodes that point at it ([maintenance.md](maintenance.md#compaction)). Entries are written and read with release and acquire ordering, so a reader racing a move sees either the old address or the new one, never a mix. @@ -185,7 +185,9 @@ image = byte 1234 × 64 = byte 78 976 of 00000001.seg ``` Entries are written when a page is allocated and forced to disk only at checkpoints (`PageStore.checkpoint`). The -commit path never syncs the directory, because every WAL record for a page carries its address too. Recovery +commit path never syncs the directory, because every WAL record for a page carries its address too. Pages a +checkpoint writes have no WAL record; the checkpoint forces the directory before it publishes the catalog image that +refers to them. Recovery writes each logged address back into the directory before it checks or replays the page, so an entry that a crash lost comes back from the log. The header's next index is updated on every allocation, and recovery raises it past every number it restores, so a restored number is never handed out a second time. @@ -198,7 +200,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. | +| `0` | No count: the number was never used, was released, or belongs to a page no checkpoint has counted 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 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. | @@ -263,14 +265,14 @@ sequenceDiagram PS->>PS: index = next index; directory[index] = (activeSegment, nextUnit, units) PS-->>M: pack(index, 0); nextUnit += units M->>H: assign(image, pageId): write pageId at offset 8, recompute the crc - M->>M: sink.accept(pageId, image, frozen): WAL record, queued write, cache admission + M->>M: sink.accept(pageId, image, frozen): page write (checkpoint) or WAL record and queued write (commit), cache admission ``` * `allocate` rejects `length > pageSize`. * Allocation is serialized by a `ReentrantLock`, and only the counter and directory updates run under it. * Rolling a segment marks the previous one `SEALED`, creates the next id, and resets `nextUnit` to 0. -* `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. +* `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. A checkpoint writes each image as soon as it has allocated it. For the pages a commit writes, 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, if a commit in the batch wrote pages. `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 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)). @@ -321,7 +323,7 @@ read-only memory, so nothing can write into a stored image through a read. 1. `NodeCache.get(pageId)`. On a hit, verify the cached node's schema id, record a hit, and return the node. 2. On a miss: `IoTrace.recordRead()`, `PageStore.read`, then `NodeCodec.decode` (which runs `verify`), then `NodeCache.put`. -3. Newly materialized nodes are admitted at commit time (`PagedNodeSource.admit`), so freshly written data is served from memory without being re-read. +3. Nodes are admitted when they are written (`PagedNodeSource.admit`), by a checkpoint or by a commit that writes a large tree, so freshly written data is served from memory without being re-read. The nodes a commit leaves in memory never pass through the cache: readers reach them through `Ref.Pending` until a checkpoint writes them. ### `NodeCache` (`tree/NodeCache.java`) @@ -356,18 +358,20 @@ When `pagesRead + cacheHits` exceeds the budget, it throws `HStoreException.limi ## Write-ahead logging of pages -For each materialized image, the commit appends one WAL record ([wal-and-recovery.md](../transactions/wal-and-recovery.md)): +Most pages are never logged. A checkpoint writes the trees of a generation whose commits are already in the WAL as key changes, and it fsyncs the pages and the directory before it publishes the catalog image that points at them ([wal-and-recovery.md](../transactions/wal-and-recovery.md#9-checkpoints)). If it crashes first, recovery replays those commits again from the previous checkpoint. + +Only pages written before a commit point are logged: pages a transaction spills, and the pages of a tree a commit logs by reference because it changed in more than 4,096 places. Each such image gets one WAL record under the transaction's id ([wal-and-recovery.md](../transactions/wal-and-recovery.md)): * `PAGE_REFERENCES` (the default) appends `PageRef(txnId, pageId, address, length, crc32c(image))`, 47 or 48 bytes. The image itself is written only to its segment, and it must be durable before the WAL record that refers to it (the data-before-log ordering in `TransactionManager.makeDurable`). Recovery puts the address back into the directory, then accepts a committed transaction only if every referenced image reads back with a valid header and matching CRC (`Recovery.intact`). Replay stops at the first commit that fails this check. * `PAGE_IMAGES` appends `Page(txnId, pageId, address, image bytes)`. Recovery puts the address back into the directory and rewrites the image there, and the data file needs no sync before the WAL. -Because images are never overwritten in place, a torn write can only damage an image that no durable commit references yet. Reference mode is safe without full-page images. +Because images are never overwritten in place, a torn write can only damage an image that neither a durable commit nor a published checkpoint references yet. Reference mode is safe without full-page images. ## Format versioning | Version | Where | Meaning | |---|---|---| -| `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`. | +| `format=7` in `/FORMAT` | `StorageEngine.verifyFormat` | Commits logged as key changes (`Changes` WAL records), tree pages written at checkpoints, no history in catalog images. Also tagged references with small member and order trees inline in their edge record, 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, `format=6` commits that logged their pages) fail to open: `uses storage format 6; this build reads format 7 (commits logged as key changes); 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 7191e0d..d29de88 100644 --- a/docs/storage/persistent-tree.md +++ b/docs/storage/persistent-tree.md @@ -11,7 +11,7 @@ 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, 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`. | +| `Node` = `Leaf` / `Branch` | `Node.java`, `Leaf.java`, `Branch.java` | The schema, an `owner` token (null means frozen and shared), a lazily computed, cached `Summary`, and a stamp recording the reference it was written as (see [Materialization](#materialization)). | | `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. | @@ -19,7 +19,8 @@ This document describes the node structure, the write path, the summaries and th | `BulkBuilder` | `BulkBuilder.java` | Bottom-up construction from ascending keys. | | `TreeCursor` | `TreeCursor.java` | Bidirectional cursor with seek and summary-based pruning. | | `TreeAlgebra`, `TreeDiff` | `TreeAlgebra.java`, `TreeDiff.java` | Intersection (leapfrog, synchronized descent, probing), union, difference, subset, equality and structural diff. | -| `Materializer` | `Materializer.java` | Freezes and encodes pending nodes into pages, post-order. | +| `Materializer` | `Materializer.java` | Freezes and encodes pending nodes into pages, post-order, at checkpoints. Also counts unwritten nodes and swaps written ones for their stored references. | +| `Epoch` | `Epoch.java` | One checkpoint attempt. Set to published once the checkpoint has published its catalog image; stamps from an unpublished epoch are ignored. | | `TreeWalker`, `TreeVerifier` | | The pages a node points at (for reference counting) and invariant checking. | ## Node structure @@ -149,7 +150,7 @@ flowchart TB end ``` -Only the path P1 → P2 → P5 is copied. P3, P4, P6 and P7 are shared by both generations as `Ref.Stored` references with identical page ids. At commit the three pending nodes are materialized into three new pages. Generation g still references the old pages, which stay valid until compaction reclaims them (see [maintenance.md](maintenance.md)). +Only the path P1 → P2 → P5 is copied. P3, P4, P6 and P7 are shared by both generations as `Ref.Stored` references with identical page ids. The commit publishes the three pending nodes as they are, and the next checkpoint writes them as three new pages. If later commits copy the same path again before that checkpoint, only the last copies are written. Generation g still references the old pages, which stay valid until compaction reclaims them (see [maintenance.md](maintenance.md)). ### Join (concatenation) @@ -162,16 +163,27 @@ The cost is O(|height difference| + 1) node copies. `TreeAlgebra.union` uses it ### Spilling large writes -A `WriteScope` built with a `PageSink` (every `Transaction` has one) can spill completed nodes to pages before commit. `BulkBuilder.sealLeaf` spills all but the two most recent leaves once more than 32 (`SPILL_BATCH`) unspilled leaves have accumulated, through `TransactionManager.spill`. Bulk-loading a hyperedge with millions of members therefore keeps memory bounded. Spilled pages are logged in the WAL like commit-time pages and counted against the transaction's page budget (`TxnOptions.maxPages`, the `transaction_page_budget` setting). +A `WriteScope` built with a `PageSink` (every `Transaction` has one) can spill completed nodes to pages before commit. `BulkBuilder.sealLeaf` spills all but the two most recent leaves once more than 32 (`SPILL_BATCH`) unspilled leaves have accumulated, through `TransactionManager.spill`. Bulk-loading a hyperedge with millions of members therefore keeps memory bounded. Spilled pages are logged in the WAL like the pages a commit writes for a large tree, and counted against the transaction's page budget (`TxnOptions.maxPages`, the `transaction_page_budget` setting). ### 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` or `Inline` one, post-order: +`Materializer.materialize(ref, schema)` (`Materializer.java`) turns a `Pending` reference into a `Stored` or `Inline` one, post-order. It runs at checkpoints, over every root of the generation being checkpointed (`Checkpointer`). A commit runs it only for a tree it logs by reference because the tree changed in more than 4,096 places (`TransactionManager.append`, see [WAL and recovery](../transactions/wal-and-recovery.md#the-changes-payload)). For each pending node: -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 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. +1. If the node carries a stamp from the reusable epoch, the last published one, return the reference in the stamp. The node was already written, and nothing below it is visited. +2. For a branch, materialize every child first, then freeze the branch (`frozenWith`, which also sets `separators[0]` to the first child's min). +3. 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`, stamped and reused the same way. Every other nested tree goes through `materialize`. Nested trees are therefore always settled before the leaf that points at them. +4. Encode with `NodeCodec.encode(frozen, PageId.NONE, epoch, pageSize)`, where `epoch` is the generation being written (the checkpointed one, or the new one at commit). Allocate a page number for exactly that length, patch it into the header (`PageHeader.assign`) and hand the image to the sink. A checkpoint's sink writes the page and admits the frozen node into the `NodeCache` under its page number, so the next reader finds it without decoding. A commit's sink does the same admission, records a WAL `Page` or `PageRef` record, and queues the write until the records are appended. +5. Stamp the in-memory node with the result and the materializer's epoch, and return `Ref.Stored(pageId, units, frozen.summary())`. Parents embed the child's full summary in their own page, which is what lets readers prune without loading children. + +**Stamps and epochs.** Every checkpoint attempt has its own `Epoch`, which `TransactionManager.checkpointed` marks published once the checkpoint's catalog image is durable. A stamp holds the reference a node was written as and the epoch that wrote it. Three things read stamps, and all three ignore a stamp whose epoch isn't published: + +* the next checkpoint reuses the pages of nodes stamped by the last published epoch instead of writing them again. A commit that writes a large tree stamps its nodes with the last published epoch, so the checkpoint after it keeps those pages as well; +* `Ref.same` treats such a node as the same tree as the reference it was written as (see [Equality](#inline-trees) below); +* the write buffer (`Materializer.unwrittenHeap`) doesn't count it. + +A checkpoint that fails before it publishes leaves its epoch unpublished, so nothing trusts the pages it wrote, and the next checkpoint writes those nodes again. + +**Rebinding.** Commits keep publishing while a checkpoint writes `g`, so by the time it publishes, the head generation may be several commits past `g` and still hold the pending nodes the checkpoint wrote. `Materializer.rebound(ref, schema, epoch)` walks the head generation's roots under the commit lock and replaces every node stamped by that epoch with its stored or inline reference. A pending parent whose children changed this way is copied with the new references; one whose children didn't change is kept as it is. Later commits then build on stored references, and the written nodes can be collected once no retained generation or open snapshot still holds them. ### Inline trees @@ -184,13 +196,13 @@ A tree is kept inline when all of these hold (`Ref.fitsInline`): * 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 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. When the leaf is written, 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. +The decision is made again every time the tree is written. An edge that grows past the limit gets a page at the next checkpoint, and one that shrinks back below it becomes inline again the next time its leaf is written. 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. +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.unwritten`, 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. +**Equality.** `Ref.same` compares stored references by page id and pending ones by identity. A pending node stamped by a published epoch is also the same as the reference it was written as, and two such nodes are the same when they were written as the same reference. That matters to a transaction that began before a checkpoint: the checkpoint swaps the head generation's pending nodes for stored references, and without this rule every tree the transaction touched would look changed. 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`). The same content test applies between a pending leaf and an inline reference, and between two pending leaves, when both are small enough that a checkpoint would store them inline (`Ref.fitsInline`). `Ref.Inline.equals` uses the content 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. @@ -294,7 +306,7 @@ n is the tree size, B the fanout (137 for 16 KiB pages), and k the number of res |---|---|---| | `get`, `contains` | ≤ height = O(log_B n) | stops early when the summary has no overlap | | `at(rank)`, `rankOf` | O(log_B n) | counts are in the references | -| `put`, `remove`, `update` | O(log_B n) read, O(log_B n) written at commit | amortized in-place within one scope | +| `put`, `remove`, `update` | O(log_B n) read, O(log_B n) written at the next checkpoint | amortized in-place within one scope | | `range(lo, hi)` | O(log_B n + k / leaf entries) | summary pruning | | `summarize(lo, hi)` | O(log_B n) | only boundary paths are loaded | | `intersectKeys` (2 trees) | O(m · log_B(n/m)) | m results, lazy | diff --git a/docs/transactions/transactions.md b/docs/transactions/transactions.md index b0dcdeb..14023b5 100644 --- a/docs/transactions/transactions.md +++ b/docs/transactions/transactions.md @@ -9,7 +9,8 @@ Source of truth: | Transaction handle, op log, footprints, read sets | `engine/src/main/java/io/hstore/engine/txn/Transaction.java` | | Mutable per-transaction tree overlay | `engine/src/main/java/io/hstore/engine/txn/Workspace.java` | | Logged operations | `engine/src/main/java/io/hstore/engine/txn/Op.java`, `EdgeAction.java` | -| Commit lock, append, publication, pinning | `engine/src/main/java/io/hstore/engine/txn/TransactionManager.java` | +| Commit lock, append, publication, pinning, write buffer | `engine/src/main/java/io/hstore/engine/txn/TransactionManager.java` | +| Key changes a commit logs | `engine/src/main/java/io/hstore/engine/txn/ChangeLog.java` | | Durability batching | `engine/src/main/java/io/hstore/engine/txn/GroupCommitter.java` | | Options and results | `TxnOptions.java`, `Isolation.java`, `CommitResult.java` | | Read-only views | `Snapshot.java`, `View.java` | @@ -78,7 +79,7 @@ During replay (`Workspace.replaying() == true`) a failed precondition, a duplica ### Spilling large workspaces -`Transaction` constructs its `WriteScope` with a `PageSink` that calls `TransactionManager.spill`. Only `BulkBuilder` spills (every `SPILL_BATCH = 32` leaves during `Hyperedge.build`, used by `Transaction.load`), so very large bulk loads do not have to hold every new leaf in memory. `spill` materializes the subtree, **writes the page images to their data segments first**, then appends the matching `Page`/`PageRef` WAL records under the transaction id (before the transaction commits), and returns a `Ref.Stored`. Writing data before logging means that by the time a checkpoint can observe an LSN past the spill records, the pages are already in the page cache, so the checkpoint's `pages.sync()` makes them durable before the records are truncated. Until the records are appended, the pages still count as unwritten for their segment, so compaction can't move them before the log says where they were written. The spilled pages count against the transaction's page budget (`spilledPages`). +`Transaction` constructs its `WriteScope` with a `PageSink` that calls `TransactionManager.spill`. Only `BulkBuilder` spills (every `SPILL_BATCH = 32` leaves during `Hyperedge.build`, used by `Transaction.load`), so very large bulk loads do not have to hold every new leaf in memory. `spill` materializes the subtree, **writes the page images to their data segments first**, then appends the matching `Page`/`PageRef` WAL records under the transaction id (before the transaction commits), and returns a `Ref.Stored`. Writing data before logging means that by the time a checkpoint can observe an LSN past the spill records, the pages are already in the page cache, so the checkpoint's `pages.checkpoint()` makes them durable before the records are truncated. Until the records are appended, the pages still count as unwritten for their segment, so compaction can't move them before the log says where they were written. The spilled pages count against the transaction's page budget (`spilledPages`). If a transaction that spilled is aborted (explicitly, or by `close()` without commit), `abort()` appends `WalRecord.Abort(txnId)`; recovery then drops its buffered records instead of reporting them as an unfinished transaction. A transaction that spilled and then crashed before `Commit` or `Abort` still appears in `Outcome.discardedTransactions`. In both cases the spilled pages are unreachable garbage reclaimed by compaction (see [maintenance](../storage/maintenance.md)). A commit that throws marks the transaction `ABORTED` directly and does not log `Abort`. @@ -103,7 +104,7 @@ flowchart LR * `oldestPinned()` exposes the oldest pinned generation to maintenance so that compaction never deletes a segment a pinned snapshot may still read. `oldestReadable()`, the smaller of that and the oldest retained generation, tells a checkpoint which dead pages it may release ([maintenance](../storage/maintenance.md#releasing-pages)). * `generationAsOf(wallTime)` returns the newest retained generation with `wallTime <= t` (used by `AS OF` time travel). -A `Snapshot` is a `View` whose trees are rooted at the pinned branch's root vector; `Snapshot.close()` unpins once (`AtomicBoolean` guard). Retention defaults: `EngineOptions.historyLimit = 64`. +A `Snapshot` is a `View` whose trees are rooted at the pinned branch's root vector; `Snapshot.close()` unpins once (`AtomicBoolean` guard). Retention defaults: `EngineOptions.historyLimit = 64`. History is kept in memory only; after a restart it starts again from the generations recovery rebuilt ([catalog and generations](../storage/catalog-and-generations.md#history-retention-and-time-travel)). ## 5. Commit @@ -112,6 +113,7 @@ A `Snapshot` is a `View` whose trees are rooted at the pinned branch's root vect ```text commit(txn): if txn.ops is empty -> READ_ONLY (no lock, no I/O) + awaitRoom() -- wait while the write buffer is full lock(commitLock) latest = head branch = latest.branch(txn.branch) @@ -123,7 +125,7 @@ commit(txn): else: txn.validateReads(branch.roots); workspace = txn.replay(branch.roots) if requestId: workspace.write(REQUESTS, hash(id), RequestRecord(id, txn.id, latest.id + 1)) merged = fast ? workspace.rebasedOnto(branch.roots) : workspace.roots() - appended = append(latest, branch.withRoots(merged), ...) -- WAL + data writes, head = next + appended = append(latest, branch.withRoots(merged), ...) -- WAL records, head = next unlock(commitLock) committer.await(appended.pending) -- durability + publication -> COMMITTED (fast) or REBASED (slow) @@ -135,6 +137,8 @@ commit(txn): `requestsStable` handles idempotency keys: if the transaction carries a request id, the `REQUESTS` slot is written inside the commit lock, so the fast path additionally requires that `REQUESTS` itself has not moved since the base. +A checkpoint can finish while a transaction is open. It swaps the in-memory nodes of the head generation for the pages it wrote them to, so the latest roots may be stored references where the transaction's base still holds the pending nodes. `Ref.same` treats a pending node as the same tree as the stored or inline reference it was written as once that checkpoint's epoch is published, and treats two small leaves that would be stored inline as the same when their entries are equal. A finished checkpoint therefore doesn't push a transaction off the fast path or make it see a conflict. + ### 5.2 Slow path: validation and replay If any touched slot moved, the commit re-executes the op log against the latest roots: @@ -159,11 +163,13 @@ A footprint is *commutative* iff the edge was a `SET` edge at the base and every Compaction moves pages without changing any root, so it doesn't affect commits. That holds for bulk loads too. The pages a load spills before commit already have page numbers and directory entries, and compaction finds pages by reading through the victim segment and checking each one against the directory, so a spilled page is moved like any other and the transaction commits normally ([maintenance](../storage/maintenance.md#interaction-with-running-transactions)). -The transaction keeps the numbers of the pages it spilled. If it aborts, its commit fails with a conflict or a page budget error, or its request id turns out to have committed already, it hands them to the reference counter, which marks them dead so the next checkpoint releases them. If it commits, any spilled page the committed trees don't reach is marked dead once the commit is counted. +The transaction keeps the numbers of the pages it spilled. If it aborts, its commit fails with a conflict or a page budget error, or its request id turns out to have committed already, it hands them to the reference counter, which marks them dead so the next checkpoint releases them. If it commits, its spilled pages are provisional until the checkpoint that covers its generation counts them; any the committed trees don't reach are marked dead then. ### 5.4 Page budget -`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. +`append` counts the nodes the transaction created that will have to be written: pending nodes owned by its `WriteScope` and reachable from the new roots, nested trees included but not the small ones that will be kept inline in their edge record (`Materializer.unwritten`). It rejects the commit with `HStoreException.limit` (`ABORTED_RESOURCE_LIMIT`, not retryable) if that count exceeds `txn.options().maxPages() - txn.spilledPages()`. Nodes the transaction reached but did not create, such as unwritten nodes of earlier commits, are not counted. The check runs before anything is encoded, so a rejected commit allocates no segment space and does no WAL or data I/O. + +The same walk adds up the heap those nodes take, which the commit adds to the write buffer once it is appended ([maintenance](../storage/maintenance.md#the-write-buffer)). ### 5.5 Outcomes @@ -224,13 +230,14 @@ Under `SNAPSHOT` both commits succeed (R replays `Insert(p)` into B, which L nev | Inside the commit lock | Outside the commit lock | |---|---| | Read `head`, duplicate-request check | Executing the transaction's operations (concurrent, lock-free) | +| Page budget check | Waiting for room in the write buffer | | Fast/slow decision, read validation, replay | Spilling bulk-loaded subtrees | -| Materialization of new nodes into page images (`Materializer`) | `fsync` of data segments and WAL (`GroupCommitter` leader) | -| WAL append of `Begin`, `Page`/`PageRef`, `Root`, `BranchMeta`, `Feed` records | Publication: feed append, `current.set`, `history.put` | -| Data page writes into the OS page cache | Waking the committing thread | -| WAL append of `Commit`, `head.set(next)`, `committer.submit(pending)` | | +| Encoding the key changes (`ChangeLog.encode`), and writing pages for a tree past 4,096 changes | `fsync` of data segments and WAL (`GroupCommitter` leader) | +| WAL append of `Begin`, `Page`/`PageRef`, `Changes`, `BranchMeta`, `Feed` records | Publication: `history.put`, `current.set`, feed append | +| Data page writes into the OS page cache, for those pages only | Waking the committing thread | +| WAL append of `Commit`, `head.set(next)`, `committer.submit(pending)` | Checkpoints writing pages, counting references and publishing the catalog | -The same lock guards `system(...)` (internal writes such as the dictionary), `createBranch`, `closeBranch` and `rewrite` (compaction). `exclusive(action)` takes the lock **and** drains the committer, which is how `Checkpointer.checkpoint()` obtains a quiescent point where `current == head` and every appended commit is durable and published. +The same lock guards `system(...)` (internal writes such as the dictionary), `createBranch` and `closeBranch`. `exclusive(action)` takes the lock **and** drains the committer, so `current == head` and every appended commit is durable and published while the action runs. A checkpoint uses it for one short step, swapping the pages it wrote into the head generation; it writes pages, syncs, counts and publishes the catalog image without the lock ([WAL and recovery](wal-and-recovery.md#9-checkpoints)). `verifyReferences` and `verifyChangeLog` use it as well. Because the lock is released before waiting for durability, the next committer can append while the previous batch is being synced. This pipelining is what lets group commit amortise `fsync`. @@ -239,20 +246,20 @@ Because the lock is released before waiting for durability, the next committer c `GroupCommitter` has no thread of its own. It keeps a FIFO `Deque`, and the committers waiting on it take turns leading. ```text -Pending(generation, publication: Runnable, published: boolean) -Barrier.makeDurable(batchSize) -- TransactionManager.makeDurable +Pending(generation, publication: Runnable, wrotePages: boolean, published: boolean) +Barrier.makeDurable(batchSize, wrotePages) -- TransactionManager.makeDurable ``` Protocol: 1. `submit(pending)` (called inside the commit lock, so queue order equals WAL commit order) appends to the queue. -2. `await(pending)` loops until its own `published` flag is set. If another committer is leading, it waits (uninterruptibly) on `progressed`. Otherwise it becomes the **leader**: it copies **the whole queue** as the batch, releases the lock, and calls `barrier.makeDurable(batch.size())` once. -3. The leader then runs each batch member's `publication` in order: `current.set(next)`, `history.put`, then `feed.append(event)` for non-empty events, `trimHistory()`, and `CrashPoint.CATALOG_PUBLISH`. Making the generation current **before** appending to the feed means a subscriber can always read the commit it is told about (see [change feed](../storage/change-feed.md#2-when-events-are-written)). +2. `await(pending)` loops until its own `published` flag is set. If another committer is leading, it waits (uninterruptibly) on `progressed`. Otherwise it becomes the **leader**: it copies **the whole queue** as the batch, releases the lock, and calls `barrier.makeDurable(batch.size(), wrotePages)` once, where `wrotePages` is true if any commit in the batch wrote pages. +3. The leader then runs each batch member's `publication` in order: `history.put`, `current.set(next)`, the commit's written pages to the reference counter as provisional, then `feed.append(event)` for non-empty events, `trimHistory()`, and `CrashPoint.CATALOG_PUBLISH`. Making the generation current **before** appending to the feed means a subscriber can always read the commit it is told about (see [change feed](../storage/change-feed.md#2-when-events-are-written)). 4. It retakes the lock, marks the batch `published`, pops it from the queue, updates `batches`/`flushedCommits`, stops leading, and `signalAll`s `progressed`. A waiter whose commit wasn't in that batch becomes the next leader. A lone committer therefore finishes on its own thread, with no hand-off to another thread. Committers that arrive while a leader is syncing queue up and are published together by the next leader, sharing one `fsync`. Only one leader runs at a time, so publication stays in generation order. The durability step runs on the committing thread, which in the server is usually a virtual thread; the JDK compensates for virtual threads blocked in file I/O by adding a carrier thread temporarily. -`makeDurable` for `Durability.SYNC`: if `walMode == PAGE_REFERENCES`, `pages.sync()` (force every dirty data segment); reach `DATA_SYNC`; `wal.sync()`; reach `WAL_SYNC`. For `Durability.ASYNC` it returns immediately: the leader still publishes in order, but nothing is forced. A single `wal.sync()` covers every commit record appended so far, including records appended after the batch was copied; those commits are simply published in the next batch. +`makeDurable` for `Durability.SYNC`: if `walMode == PAGE_REFERENCES` and a commit in the batch wrote pages (it spilled, or logged a tree by reference), `pages.sync()` (force every dirty data segment); reach `DATA_SYNC`; `wal.sync()`; reach `WAL_SYNC`. For `Durability.ASYNC` it returns immediately: the leader still publishes in order, but nothing is forced. Most commits write no pages, so most batches force only the WAL. A single `wal.sync()` covers every commit record appended so far, including records appended after the batch was copied; those commits are simply published in the next batch. Failure handling: any `Throwable` from the barrier or a publication is stored in `failure`, logged at `ERROR`, and `progressed` is signalled. The leader rethrows it, and every waiting and later committer gets it too. Two checks consult it: @@ -278,18 +285,18 @@ sequenceDiagram participant F as ChangeFeed C->>TM: commit(txn) activate TM - TM->>TM: fast? rebase? materialize - TM->>W: append [Begin, PageRef*, Root*, Feed] - TM->>D: write page images + TM->>TM: fast? rebase? encode key changes + TM->>W: append [Begin, Changes, Feed] TM->>W: append Commit(txn, g) TM->>TM: head = g TM->>G: submit(Pending g) deactivate TM C->>G: await(g) - G->>D: sync() (PAGE_REFERENCES only) + G->>D: sync() (PAGE_REFERENCES, only if the batch wrote pages) G->>W: sync() + G->>TM: publication: history.put(g), current = g G->>F: append(CommitEvent g) - G->>TM: publication: current = g, history.put(g), trimHistory + G->>TM: trimHistory G-->>C: published ``` @@ -352,4 +359,6 @@ Had T2 also touched `m1`, or had `E` been an `ORDERED` edge, the overlap check w * `createBranch` appends a `BranchMeta` record and a branch whose roots are the parent's current roots (copy-on-write makes this O(number of slots)). Names must be unique among active branches; the new id is one past the largest existing id. * `closeBranch` (`MERGED` or `DROPPED`, never for `MAIN`) empties the root vector so the branch's pages become unreachable. +Neither logs a `Changes` record. The `BranchMeta` record says what the roots become: on replay a new `ACTIVE` branch takes its parent's roots and a closed one gets empty roots ([WAL and recovery](wal-and-recovery.md#8-recovery)). + These commits carry no member or slot changes, so their `CommitEvent` is empty and nothing is written to the change feed (see [change feed](../storage/change-feed.md)). diff --git a/docs/transactions/wal-and-recovery.md b/docs/transactions/wal-and-recovery.md index e0c8cdb..f117699 100644 --- a/docs/transactions/wal-and-recovery.md +++ b/docs/transactions/wal-and-recovery.md @@ -2,6 +2,8 @@ This document specifies the on-disk write-ahead log byte for byte, the two WAL modes, the two durability levels, the exact ordering of writes and syncs in a commit, the crash points that the test-suite injects, the recovery algorithm, and checkpointing. +A commit logs the keys it changed, not the pages it changed. Its new tree nodes stay in memory, reachable from the published generation, until a checkpoint writes them to pages. Recovery rebuilds the commits since the last checkpoint by replaying those key changes onto the checkpoint's trees. + Source of truth: | Concern | File | @@ -9,12 +11,13 @@ Source of truth: | Record types and payload codecs | `engine/src/main/java/io/hstore/engine/wal/WalRecord.java` | | Segment files, framing, torn-tail handling | `engine/src/main/java/io/hstore/engine/wal/WriteAheadLog.java` | | What a commit writes and in which order | `engine/src/main/java/io/hstore/engine/txn/TransactionManager.java` (`append`, `makeDurable`, `spill`) | +| Key changes logged by a commit | `engine/src/main/java/io/hstore/engine/txn/ChangeLog.java` | | Crash points | `engine/src/main/java/io/hstore/engine/txn/CrashPoint.java` | | Recovery | `engine/src/main/java/io/hstore/engine/maintenance/Recovery.java` | -| Checkpoints | `engine/src/main/java/io/hstore/engine/maintenance/Checkpointer.java`, `catalog/CatalogStore.java` | +| Checkpoints | `engine/src/main/java/io/hstore/engine/maintenance/Checkpointer.java`, `tree/Materializer.java`, `catalog/CatalogStore.java` | | Reference counts | `engine/src/main/java/io/hstore/engine/maintenance/ReferenceCounter.java`, `page/ReferenceCounts.java`, `page/ReferenceJournal.java` | | Byte encodings | `engine/src/main/java/io/hstore/engine/page/ByteCursor.java`, `tree/Ref.java`, `tree/Summary.java` | -| Crash test matrix | `engine/src/test/java/io/hstore/engine/CrashRecoveryTest.java` | +| Crash test matrix | `engine/src/test/java/io/hstore/engine/CrashRecoveryTest.java`, `DeferredWritesTest.java` | Related: [transactions](transactions.md), [change feed](../storage/change-feed.md), [pages](../storage/pages.md), [catalog and generations](../storage/catalog-and-generations.md), [maintenance](../storage/maintenance.md), [configuration](../operations/configuration.md). @@ -24,7 +27,7 @@ Related: [transactions](transactions.md), [change feed](../storage/change-feed.m ```text / - FORMAT properties: format=6, page-size= + FORMAT properties: format=7, 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 +52,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` | `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)) | +| `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 for a nested tree, inside a catalog record or a nested tree change of a `Changes` record, never for a slot root ([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 @@ -63,7 +66,7 @@ offset size field 0 4 i32 payload length P (0 <= P <= 2^30) 4 4 i32 CRC-32C over bytes [8, 25 + P) 8 8 i64 LSN of this frame (must equal its position in the stream) -16 1 u8 record type (1..9) +16 1 u8 record type (1..10) 17 8 i64 transaction id 25 P payload (per type, section 4) ``` @@ -94,14 +97,36 @@ Only the tail can legitimately be torn, because every earlier segment was forced | Type | Record | Payload layout | Emitted by | |---|---|---|---| | 1 | `Begin(txnId, branch, baseGeneration)` | `varint branch`, `varlong baseGeneration` | first record of every commit (`append`) | -| 2 | `Page(txnId, pageId, address, image)` | `i64 pageId`, `i64 address`, `blob image` | `PAGE_IMAGES` mode, one per materialized page | -| 3 | `Root(txnId, branch, slot, root)` | `varint branch`, `varint slot`, `Ref root` | one per slot whose root changed (`Ref.same` false), in ascending slot order | +| 2 | `Page(txnId, pageId, address, image)` | `i64 pageId`, `i64 address`, `blob image` | `PAGE_IMAGES` mode, one per page written before the commit point: spilled pages, and trees a `Changes` record logs by reference | +| 3 | `Root(txnId, branch, slot, root)` | `varint branch`, `varint slot`, `Ref root` | not written since format 7. The decoder still reads it and recovery still applies it | | 4 | `BranchMeta(txnId, id, name, parent, baseGeneration, createdAt, state)` | `varint id`, `string name`, `varint parent`, `varlong baseGeneration`, `varlong createdAt`, `u8 state` (`ACTIVE=0`, `MERGED=1`, `DROPPED=2`) | `createBranch`, `closeBranch` | | 5 | `Feed(txnId, payload)` | `blob` containing a `FeedCodec`-encoded `CommitEvent` | commits with at least one member or slot change | | 6 | `Commit(txnId, generation, wallTime, nextAtom, nextTxn)` | `varlong generation`, `varlong wallTime`, `varlong nextAtom`, `varlong nextTxn` | last record of every commit, appended separately | | 7 | `Abort(txnId)` | empty | `Transaction.abort()` via `TransactionManager.logAbort`, only for transactions that spilled pages (the only case in which a transaction has WAL records before its commit) | | 8 | `Checkpoint(0, generation, lsn)` | `varlong generation`, `varlong lsn` | `Checkpointer`, transaction id is always 0 | -| 9 | `PageRef(txnId, pageId, address, length, checksum)` | `i64 pageId`, `i64 address` (a packed `PageAddress`), `varint length`, `i32 checksum` (CRC-32C of the page image's first `length` bytes) | `PAGE_REFERENCES` mode, one per materialized page | +| 9 | `PageRef(txnId, pageId, address, length, checksum)` | `i64 pageId`, `i64 address` (a packed `PageAddress`), `varint length`, `i32 checksum` (CRC-32C of the page image's first `length` bytes) | `PAGE_REFERENCES` mode, for the same pages as `Page` | +| 10 | `Changes(txnId, branch, payload)` | `varint branch`, `blob payload` (below) | every commit except branch creation and closing, after its `Begin` and page records | + +Pages a checkpoint writes are never logged. The checkpoint syncs them before it publishes the catalog image that points at them (section 9). + +### The `Changes` payload + +`ChangeLog.encode(before, after, ...)` compares the branch's root vector in the generation the commit builds on with the one it publishes. A slot is listed when `Ref.same(before, after)` is false, in ascending slot order: + +```text +payload := varint slotCount | (varint slotId | treeChange)* +treeChange := u8 2 UNCHANGED: keep the base tree (nested trees only) + | u8 1 | Ref ROOT: the tree is this reference + | u8 0 | varint n | delta*n DELTA: apply n key changes to the base tree +delta := i64 key | u8 0 REMOVE + | i64 key | u8 1 | blob value | treeChange* PUT +``` + +* `ROOT` is written when the new root is not an in-memory node: a stored reference, an inline one or the empty tree, encoded with `Ref.write`. +* `DELTA` lists the keys `TreeDiff` finds between the two trees, in key order. A `PUT` value is the tree codec's encoding of a one-entry leaf holding the key and the value, with every nested tree reference replaced by an empty one. One `treeChange` per nested reference follows, in `forEachRef` order, each relative to the nested tree at the same position in the value it replaces. The base is the empty tree when there was no value or the nested schema at that position differs. An edge record therefore logs its changed members, not its whole member tree. +* A tree whose entry count moved by more than 4,096 (`ChangeLog.MAX_DELTA`), or whose diff has more than 4,096 entries, is written to pages at commit and logged as `ROOT` with the stored reference. Its pages get `Page` or `PageRef` records in the same commit, like spilled pages. A large bulk load ends up here. + +`ChangeLog.apply` does the reverse: it reads each slot's change and applies it to the slot's tree in the base root vector, building nested trees the same way. Frame sizes that follow directly from the layouts (25-byte header included): @@ -111,7 +136,7 @@ Frame sizes that follow directly from the layouts (25-byte header included): | `PageRef` with `length = 16384` | 25 + 8 + 8 + 3 + 4 = **48** | | `Page` with image length `L` (128 <= L < 16384) | 25 + 8 + 8 + 2 + L = **43 + L** | | `Commit` | 25 + 4 varlongs; with a 6-byte millisecond wall clock typically 34 to 42 | -| `Root` | 25 + 2 + 8 + summary; empty tree 35, otherwise typically 60 to 130 | +| `Changes` | 25 + `varint branch` + `blob payload`. Each changed key costs 9 bytes plus, for a `PUT`, its encoded value and nested tree changes; each changed slot adds its id and a kind byte | | `Checkpoint` | 25 + 2 varlongs | ### Example frame @@ -132,40 +157,43 @@ c8 1e varint 3912 (0x0F48): low 7 bits 0x48 | 0x80, then 0x1E ## 5. What one commit writes -`TransactionManager.append` runs inside the commit lock. For a commit of transaction `t` producing generation `g = head.id + 1`: +Before it takes the commit lock, a committing transaction checks the write buffer (`awaitRoom`, see [maintenance](../storage/maintenance.md#the-write-buffer)). `TransactionManager.append` then runs inside the commit lock. For a commit of transaction `t` producing generation `g = head.id + 1`: ```mermaid flowchart TD - A[materialize pending nodes
allocate fresh PageIds, encode images] --> B{pages > budget?} + A[count the unwritten nodes the transaction created] --> B{nodes > budget?} B -- yes --> X[ResourceLimit, nothing written] - B -- no --> P((PAGE)) - P --> R[records = Begin, Page or PageRef per page,
Root per changed slot, BranchMeta, Feed] + B -- no --> E[ChangeLog.encode against the previous roots of the branch,
writing pages for any tree past 4,096 changes] + E --> P((PAGE)) + P --> R[records = Begin, Page or PageRef per written page,
Changes, BranchMeta, Feed] R --> W1[wal.append records] W1 --> WA((WAL_APPEND)) - WA --> D[pages.write every image] + WA --> D[pages.write every image, usually none] D --> DW((DATA_WRITE)) DW --> C[wal.append Commit t g] C --> CA((COMMIT_APPEND)) CA --> H[head = g, committer.submit] H --> F[commit leader: makeDurable] - F --> DS[[PAGE_REFERENCES and SYNC: pages.sync]] + F --> DS[[SYNC and PAGE_REFERENCES, if a commit in the batch wrote pages: pages.sync]] DS --> DSP((DATA_SYNC)) DSP --> WS[[SYNC: wal.sync]] WS --> WSP((WAL_SYNC)) - WSP --> FD[feed.append event, if non-empty] - FD --> PUB[current = g, history.put, trimHistory] - PUB --> CP((CATALOG_PUBLISH)) + WSP --> PUB[history.put, current = g,
written pages to the reference counter as provisional] + PUB --> FD[feed.append event if non-empty, then trimHistory] + FD --> CP((CATALOG_PUBLISH)) ``` +The published generation's roots point at in-memory nodes (`Ref.Pending`). They stay that way until a checkpoint writes them (section 9), so an ordinary commit writes no pages and its only data on disk is its WAL records. + Circles are `CrashPoint`s, reached through `storage.faults().reach(point)` exactly where drawn. The enum order is the order of the write path: ```text PAGE < WAL_APPEND < DATA_WRITE < COMMIT_APPEND < DATA_SYNC < WAL_SYNC < CATALOG_PUBLISH ``` -The `Commit` record is appended in a separate call **after** the data pages were written. It is the atomic commit point: a transaction is part of the recovered state if and only if its `Commit` frame is valid in the recovered log and the durability-prefix rule (section 8) admits it. Consequently a crash at any point `>= COMMIT_APPEND` that preserves the operating system's page cache recovers the commit, and a crash at any point `< COMMIT_APPEND` recovers the prior state. +The `Commit` record is appended in a separate call **after** any data pages were written. It is the atomic commit point: a transaction is part of the recovered state if and only if its `Commit` frame is valid in the recovered log and the durability-prefix rule (section 8) admits it. Consequently a crash at any point `>= COMMIT_APPEND` that preserves the operating system's page cache recovers the commit, and a crash at any point `< COMMIT_APPEND` recovers the prior state. -Spilled subtrees (`TransactionManager.spill`, only for bulk loads) are handled earlier, outside the commit lock: the page images are written to their segments **first**, then the `Page`/`PageRef` records are appended, carrying the transaction id but no `Begin`. Recovery attaches them to the transaction's body by id. The data-before-log order matters for checkpoints, which can run while a spill is in progress. A checkpoint reads its LSN *before* it syncs pages and the page directory (section 9), so every spill record below that LSN was appended after its page was written and its directory entry set, and both are made durable before `truncateBefore` drops the record. If the transaction is aborted, `WalRecord.Abort` follows the spill records and its pages are marked dead for the next checkpoint to release ([maintenance](../storage/maintenance.md#reference-counts)). +Spilled subtrees (`TransactionManager.spill`, only for bulk loads) are handled earlier, outside the commit lock: the page images are written to their segments **first**, then the `Page`/`PageRef` records are appended, carrying the transaction id but no `Begin`. Recovery attaches them to the transaction's body by id. The data-before-log order matters for checkpoints, which can run while a spill is in progress. A checkpoint's LSN is the end of a commit that was already published when the checkpoint started, and the checkpoint syncs pages and the page directory after that (section 9). Every spill record below that LSN was therefore appended after its page was written and its directory entry set, and both are durable before `truncateBefore` drops the record. If the transaction is aborted, `WalRecord.Abort` follows the spill records and its pages are marked dead for the next checkpoint to release ([maintenance](../storage/maintenance.md#reference-counts)). ## 6. WAL modes @@ -173,20 +201,22 @@ Spilled subtrees (`TransactionManager.spill`, only for bulk loads) are handled e ### PAGE_IMAGES -Every materialized page is logged in full (`Page`). At commit (`SYNC`) only the WAL is forced; data segments are forced at the next checkpoint (`Checkpointer` calls `pages.sync()` before publishing the catalog image). Recovery rewrites every logged image into its segment (`redo`), so the data files may be arbitrarily stale or torn at crash time. +Every page a commit writes is logged in full (`Page`). At commit (`SYNC`) only the WAL is forced; data segments are forced at the next checkpoint (`Checkpointer` calls `pages.checkpoint()` before publishing the catalog image). Recovery rewrites every logged image into its segment (`redo`), so those pages may be arbitrarily stale or torn at crash time. ### PAGE_REFERENCES -Every materialized page is logged as a 47 or 48 byte `PageRef` holding its id, address, length and CRC-32C. The image itself lives only in the data segment. This is safe because of two invariants: +Every page a commit writes is logged as a 47 or 48 byte `PageRef` holding its id, address, length and CRC-32C. The image itself lives only in the data segment. This is safe because of two invariants: -1. **Pages are never overwritten in place while reachable.** The tree is copy-on-write: a commit writes new versions of changed nodes to *freshly allocated* `PageId`s and never modifies a page that any generation, pinned snapshot or retained catalog image can reach. Compaction copies live pages to new addresses under the same numbers and only reclaims a segment after a checkpoint has moved the WAL past every record that could reference it ([maintenance](../storage/maintenance.md)). A page number is never handed out twice, and `PageHeader.verify` rejects an image whose stored number differs from the requested one. -2. **Data is forced before the log.** `makeDurable` calls `pages.sync()` (every dirty segment file, `force(false)`) and only then `wal.sync()`. When the commit's `Commit` frame is durable, so is every page it references. +1. **Pages are never overwritten in place while reachable.** The tree is copy-on-write: new versions of changed nodes, whether a checkpoint or a commit writes them, go to *freshly allocated* `PageId`s, and nothing modifies a page that any generation, pinned snapshot or retained catalog image can reach. Compaction copies live pages to new addresses under the same numbers and only reclaims a segment after a checkpoint has moved the WAL past every record that could reference it ([maintenance](../storage/maintenance.md)). A page number is never handed out twice, and `PageHeader.verify` rejects an image whose stored number differs from the requested one. +2. **Data is forced before the log.** When a commit in the group-commit batch wrote pages, `makeDurable` calls `pages.sync()` (every dirty segment file, `force(false)`) and only then `wal.sync()`. When the commit's `Commit` frame is durable, so is every page it references. A batch whose commits wrote no pages syncs only the WAL. Invariant 2 alone is not enough: the OS may write the WAL segment back to disk on its own *before* `pages.sync()` runs, and a power failure at that moment leaves a durable `Commit` frame whose pages are missing. That commit was never acknowledged (acknowledgement happens after `WAL_SYNC` and publication). Recovery detects the situation by verifying every `PageRef` (section 8) and discards the commit together with everything after it. ### Write amplification -Let a commit materialize `N` pages with mean image length `L` (images are `PageHeader.SIZE = 80` bytes plus the encoded node, at most the page size `P`; `NodeCodec.encode` returns a slice of exactly that length). Ignoring the per-commit constant (one `Begin`, a few `Root`s, an optional `Feed`, one `Commit`: typically 150 to 400 bytes): +An ordinary commit writes no pages. Its WAL cost is one `Begin`, one `Changes` record, an optional `Feed` and one `Commit`, and the `Changes` record grows with the keys changed, not with tree height. The pages come later: a checkpoint writes every node changed since the previous checkpoint once, however many commits changed it in between. A leaf that many commits touched is written once per checkpoint, not once per commit, and so are the branch nodes above it. `DeferredWritesTest.smallCommitsWriteNoPagesAndACheckpointWritesEachNodeOnce` checks that 300 single-insert commits write no pages and that the checkpoint after them writes fewer than 60. + +The WAL modes differ only for the pages a commit writes itself: spills and trees logged by reference. Let a commit write `N` such pages with mean image length `L` (images are `PageHeader.SIZE = 80` bytes plus the encoded node, at most the page size `P`; `NodeCodec.encode` returns a slice of exactly that length). Ignoring the per-commit records: | | `PAGE_IMAGES` | `PAGE_REFERENCES` | |---|---|---| @@ -197,7 +227,7 @@ Let a commit materialize `N` pages with mean image length `L` (images are `PageH | Forced later | data segments and the page directory at checkpoint | the page directory at checkpoint | | Recovery source of page bytes | WAL | data segments (verified) | -Worked numbers for the default page size `P = 16 KiB` and a commit that rewrites a root-to-leaf path in three trees of height 4 (`N = 12`): +Worked numbers for the default page size `P = 16 KiB` and `N = 12`: | `L` | `PAGE_IMAGES` total | `PAGE_REFERENCES` total | Ratio | |---|---|---|---| @@ -205,7 +235,7 @@ Worked numbers for the default page size `P = 16 KiB` and a commit that rewrites | 8,000 | 12 x 16,043 = 192,516 B | 12 x 8,047 = 96,564 B | 1.99x | | 1,024 | 12 x 2,091 = 25,092 B | 12 x 1,071 = 12,852 B | 1.95x | -`PAGE_REFERENCES` halves device write volume and shrinks the log by roughly `L / 47`, which also shortens recovery scans and makes checkpoints rarer for the same `checkpointWalBytes`. The price is a second `fsync` target per group-commit batch (the dirty segment files), amortised across the batch like the WAL sync. +For those pages `PAGE_REFERENCES` halves device write volume and shrinks the log by roughly `L / 47`. The price is a second `fsync` target for a group-commit batch that wrote pages (the dirty segment files), amortised across the batch like the WAL sync. Pages a checkpoint writes cost the same in both modes: they are written once and synced by the checkpoint. ## 7. Durability levels @@ -213,7 +243,7 @@ Worked numbers for the default page size `P = 16 KiB` and a commit that rewrites | Mode | Commit acknowledged after | Data at risk on power loss | Never possible | |---|---|---|---| -| `SYNC` + `PAGE_REFERENCES` | data `fsync`, WAL `fsync`, publication | nothing acknowledged | a recovered state that mixes a commit's effects | +| `SYNC` + `PAGE_REFERENCES` | data `fsync` (only if the batch wrote pages), WAL `fsync`, publication | nothing acknowledged | a recovered state that mixes a commit's effects | | `SYNC` + `PAGE_IMAGES` | WAL `fsync`, publication | nothing acknowledged | same | | `ASYNC` (either mode) | publication only, no `fsync` | acknowledged commits after the last checkpoint or OS write-back | same; recovery yields a prefix of the commit order | @@ -237,28 +267,29 @@ sequenceDiagram SE->>PS: open(segments and checkpoint lsn from the image): apply pages.ref.journal if it matches SE->>WAL: open: truncate the last segment's torn tail SE->>CF: open: truncate the feed's torn tail - SE->>R: recover(image, wal, pages, feed, codec) + SE->>SE: new NodeCache and PagedNodeSource + SE->>R: recover(image, wal, pages, feed, codec, slots, source) R-->>SE: Outcome(image, replayed, discardedTransactions, discardedCommits) - SE->>SE: ReferenceCounter.recover: count the replayed generations, scan for unreferenced pages + SE->>SE: ReferenceCounter.recover: rebuild the counts if marked, scan for uncounted pages SE->>SE: new TransactionManager(recovered current, history) - SE->>CK: checkpoint() (release dead pages, publish image, truncate WAL) + SE->>CK: checkpoint() (write the replayed trees, count, release dead pages, publish image, truncate WAL) SE->>SE: compactor.reclaim(), start the reference counter and maintenance threads ``` -`Recovery.recover(checkpoint, wal, pages, feed, codec)`: +`Recovery.recover(checkpoint, wal, pages, feed, codec, slots, source)`: 1. **Scan.** Read the WAL from `checkpoint.checkpointLsn()` (may throw `CORRUPT_LOG`, section 3). `Commit` frames are collected in log order; `Abort` drops the transaction's buffered spill records; `Checkpoint` is ignored; every other record is buffered in `pending[txnId]`. -2. **Filter.** For each commit in log order, remove its body from `pending`. Skip it if `commit.generation <= checkpoint.current().id()` (already in the image). Otherwise write the address of every `Page` and `PageRef` in the body back into the page directory (`PageStore.place`), which also moves the directory's next index past that number. Entries written after the last checkpoint may not have reached the disk; this restores them. +2. **Filter.** For each commit in log order, remove its body from `pending`. Skip it if `commit.generation <= checkpoint.current().id()` (already in the image). Otherwise write the address of every `Page` and `PageRef` in the body (spilled pages and trees logged by reference) back into the page directory (`PageStore.place`), which also moves the directory's next index past that number. Entries written after the last checkpoint may not have reached the disk; this restores them. 3. **Durability prefix.** If any earlier commit was discarded, discard this one too. Otherwise check `intact(body, pages)`: for every `PageRef` in the body, read the page, run `PageHeader.verify(page, pageId)` (magic, format, identity, header checksum), and require `page.byteSize() >= length` and `crc32c(page[0, length)) == checksum`. Any exception or mismatch means the page never reached storage; log a warning once (`durable prefix ends before generation g`) and discard this and every later commit. `Page` records need no verification because they carry their own bytes, protected by the frame CRC. -4. **Redo.** Apply the body's `BranchMeta` records first, then the rest in order. `createBranch` logs a new branch's `Root` records ahead of its `BranchMeta`, so the branch has to exist before its roots are set. `Page` writes the image at the address it carries; `BranchMeta` inserts or updates the branch (keeping its current roots); `Root` sets one slot root of one branch; `Feed` appends the decoded event to the change feed (a no-op if the feed already holds that generation). Build `Generation(commit.generation, commit.wallTime, commit.txnId, max(nextAtom), max(nextTxn), branches)` and append it to the history. +4. **Redo.** Apply the body's `BranchMeta` records first, then the rest in order. `BranchMeta` inserts or updates the branch: a new `ACTIVE` branch takes its parent's roots, an existing `ACTIVE` branch keeps its own, and a `MERGED` or `DROPPED` branch gets empty roots. `Changes` replays the commit's key changes onto the branch's current roots with `ChangeLog.apply`, under a fresh `WriteScope` for this commit that is frozen afterwards, so the next commit copies these nodes instead of changing them. `Page` writes the image at the address it carries; `Root`, which this build no longer writes, sets one slot root of one branch; `Feed` appends the decoded event to the change feed (a no-op if the feed already holds that generation). Build `Generation(commit.generation, commit.wallTime, commit.txnId, max(nextAtom), max(nextTxn), branches)` and append it to the history. 5. **Seal.** `pages.sync()`; `feed.truncateAfter(recovered generation)` drops feed frames for commits that did not survive (possible under `ASYNC`, or after the prefix rule fired); `feed.sync()`. -6. **Result.** `CatalogImage(recovered generation, history, segments, wal.end(), feed.size())` and `Outcome(image, replayed, discardedTransactions = pending.size(), discardedCommits)`, where `replayed` lists the generations redone, in order. `discardedTransactions` counts transactions that wrote records but neither a `Commit` nor an `Abort` (crashed mid-commit, or spilled and then lost to a crash before committing or aborting). +6. **Result.** `CatalogImage(recovered generation, history, segments, wal.end(), feed.size())` and `Outcome(image, replayed, discardedTransactions = pending.size(), discardedCommits)`, where `replayed` lists the generations redone, in order, and `history` is the image's history followed by the replayed generations. `discardedTransactions` counts transactions that wrote records but neither a `Commit` nor an `Abort` (crashed mid-commit, or spilled and then lost to a crash before committing or aborting). -The reference counts in `pages.ref` describe the checkpointed generation. `ReferenceCounter.recover` brings them forward by counting each replayed generation against the one before it, as the counter does after a commit, and then scans the page directory. A page with an address but no count and no death mark was written by a transaction that never committed, or by a commit the prefix rule discarded, so its entry is cleared. If `pages.ref` is marked for rebuilding, the counts are recomputed from the retained generations instead ([maintenance](../storage/maintenance.md#reference-counts)). +The replayed generations live in memory: their trees are pending nodes built on the checkpoint's pages, read through the node cache and source that the constructor creates before recovery. Nothing is written for them until the checkpoint at the end of the open. The reference counts in `pages.ref` describe the checkpointed generation and stay there; that checkpoint counts the generation it writes against it. If `pages.ref` is marked for rebuilding, the counts are first recomputed from the checkpointed generation and the image's history. `ReferenceCounter.recover` then scans the page directory. A page with an address but no count and no death mark was written after the last checkpoint: spilled, written by a commit, written by a checkpoint that never published, or written by a transaction that never committed. Its entry is kept, and the page is handed to the first checkpoint as provisional, which counts it if the generation it writes reaches the page and marks it dead otherwise ([maintenance](../storage/maintenance.md#reference-counts)). -The prefix rule is what makes recovery produce *the state after some prefix of the commit order*. Without it, a durable commit `k+1` whose pages survived could be replayed on top of a lost commit `k`, and its root records (which point at pages built on `k`'s trees) would expose a mixture. +The prefix rule is what makes recovery produce *the state after some prefix of the commit order*. Without it, a durable commit `k+1` whose pages survived could be replayed on top of a lost commit `k`, and its key changes, which apply to `k`'s trees, would expose a mixture. -Recovery is idempotent: it writes nothing to the WAL, `Page` redo rewrites identical bytes, feed appends are keyed by generation, and the constructor immediately publishes a new checkpoint. A crash during recovery repeats the same work on the next open. +Recovery is idempotent: it writes nothing to the WAL, `Page` redo rewrites identical bytes, the replayed trees exist only in memory, feed appends are keyed by generation, and the constructor immediately publishes a new checkpoint. A crash during recovery repeats the same work on the next open. The log line written at the end has the form: @@ -268,24 +299,30 @@ LOG: [recovery] recovered generation 158: replayed 0 commits from lsn 156,490, ## 9. Checkpoints -`Checkpointer.checkpoint()` first calls `pages.checkpoint()` without any lock, so the data written since the last checkpoint is mostly on disk before commits are held up. Steps 1 to 5 then run inside `TransactionManager.exclusive(...)`, which takes the commit lock **and** drains the group committer, so `current == head` and nothing is in flight. Inside that, `ReferenceCounter.pause` counts every published commit that is still queued and keeps the counter paused until step 6 is done. +A checkpoint writes the trees of one published generation to pages and makes that generation the starting point of the next recovery. `Checkpointer.checkpoint()` takes the commit lock for one short step only (step 9), so commits keep running while it writes and syncs. -1. `lsn = wal.end()`. It is read first so that everything logged below it was written before the syncs that follow. -2. Dead pages that no retained or pinned generation can reach are released ([maintenance](../storage/maintenance.md#releasing-pages)). -3. `pages.checkpoint()` (every dirty segment, then the page directory), then `pages.journal(lsn)`: every count changed since the last checkpoint and every released directory entry, as absolute values, go to `pages.ref.journal` behind a header holding `lsn` and with a CRC-32C at the end, and the file is forced. `feed.sync()` (forces the active feed segment; earlier feed segments were forced when the feed rolled). `history` = retained generations other than `current`, newest first, at most `historyLimit`. -4. `catalog.publish(CatalogImage(current, history, segments, lsn, feed.size()))`. The image is framed as `i32 magic 0x54414348`, `i32 crc32c(payload)`, `i64 length`, payload; written to `generations/%016x.cat.tmp`, forced, atomically renamed, the directory forced; then `manifest` is rewritten the same way. The three newest `.cat` files are kept; `loadLatest()` falls back to the newest valid file if the manifest is missing or names a corrupt file. -5. `wal.append(Checkpoint(0, current.id, lsn))`, `wal.sync()`, `wal.truncateBefore(lsn)`. -6. With the commit lock released, `pages.applyJournal()` writes the journaled values into `pages.ref` and `pages.dir`, forces both and truncates the journal to zero bytes. Commits run meanwhile; the counter queues their changes and resumes once the journal is applied. +1. **Target.** `g = transactions.current()`, the newest published generation, and a new `Epoch` for this attempt. +2. **Write the pages.** A `Materializer` walks every branch's roots in `g` and writes each pending node it reaches, children first: `pages.allocate`, `pages.write`, then the frozen node is admitted to the node cache and the in-memory node is stamped with its stored reference and the epoch. A node stamped by the last published epoch was written by the previous checkpoint, or by a commit that logged its tree by reference, so its stored reference is reused and the subtree below it is not visited. The images carry `g` as their creation epoch. The result is `g` with every root stored. +3. `CHECKPOINT_PAGES`, then `pages.checkpoint()` fsyncs every dirty segment file and forces the page directory. If anything up to here fails, the pages written so far are handed to the reference counter as discarded, so a later checkpoint releases them. +4. The reference counter is paused (`ReferenceCounter.pause`) until step 11 is done. If the previous checkpoint's journal was never applied, it is applied now (`settleJournal`). +5. **LSN.** `lsn` is the WAL position just after `g`'s `Commit` record (`TransactionManager.commitEnd`), or the previous checkpoint's LSN when `g` is the generation that checkpoint already wrote. Commits published after `g` lie above `lsn` and stay in the log. +6. **Count.** `ReferenceCounter.checkpointed(previous, written)` compares the previously checkpointed generation with the one just written and updates the counts ([maintenance](../storage/maintenance.md#reference-counts)). Dead pages that no retained or pinned generation can reach are then released ([maintenance](../storage/maintenance.md#releasing-pages)). +7. `pages.checkpoint()` again, then `pages.journal(lsn)`: every count changed since the last checkpoint and every released directory entry, as absolute values, go to `pages.ref.journal` behind a header holding `lsn` and with a CRC-32C at the end, and the file is forced. `feed.sync()` (forces the active feed segment; earlier feed segments were forced when the feed rolled). +8. `CHECKPOINT_CATALOG`, then `catalog.publish(CatalogImage(written g, List.of(), segments, lsn, feed.size()))`. Retained history is not written into the image. The image is framed as `i32 magic 0x54414348`, `i32 crc32c(payload)`, `i64 length`, payload; written to `generations/%016x.cat.tmp`, forced, atomically renamed, the directory forced; then `manifest` is rewritten the same way. The three newest `.cat` files are kept; `loadLatest()` falls back to the newest valid file if the manifest is missing or names a corrupt file. +9. `TransactionManager.checkpointed(written g, epoch)` publishes the epoch and forgets the commit positions of generations before `g`. Then, holding the commit lock and with the group committer drained, it swaps the written pages into the head generation: every node there that this checkpoint wrote is replaced with its stored reference, and parents that only changed because of that are copied (`Materializer.rebound`). Head, `current` and head's history entry become the rebound generation. Last, it recounts the write buffer and wakes commits that were waiting for room. +10. `wal.append(Checkpoint(0, g, lsn))`, `wal.sync()`, `wal.truncateBefore(lsn)`. +11. `CHECKPOINT_JOURNAL`, then `pages.applyJournal()` writes the journaled values into `pages.ref` and `pages.dir`, forces both and truncates the journal to zero bytes. The checkpoint remembers `lsn` and resumes the counter. -Because the journal holds absolute values, applying it twice gives the same files. If the process dies after step 4 but before step 6 is finished, the next open finds a journal whose LSN matches the published catalog and applies it again. If it dies before step 4, the journal's LSN doesn't match and it is ignored: `pages.ref` and `pages.dir` were not touched, so they still match the previous catalog. If step 5 or 6 fails and the engine keeps running, the next checkpoint applies the old journal before writing a new one, and a failed step 4 marks `pages.ref` for rebuilding. `ReferenceCountTest` crashes at `CHECKPOINT_CATALOG`, just before step 4, and at `CHECKPOINT_JOURNAL`, between steps 5 and 6, and checks the counts after reopening, including after a checkpoint that failed at step 6 and a next one that crashed before step 4. +Because the journal holds absolute values, applying it twice gives the same files. If the process dies after step 8 but before step 11 is finished, the next open finds a journal whose LSN matches the published catalog and applies it again. If it dies before step 8, the journal's LSN doesn't match and it is ignored: `pages.ref` and `pages.dir` were not touched, so they still match the previous catalog, and the pages this checkpoint wrote have addresses but no counts, so the next open hands them to its first checkpoint, which finds nothing that reaches them and marks them dead. If step 10 or 11 fails and the engine keeps running, the next checkpoint applies the old journal before writing a new one, and a failed step 8 marks `pages.ref` for rebuilding. `ReferenceCountTest` crashes at `CHECKPOINT_CATALOG` and at `CHECKPOINT_JOURNAL` and checks the counts after reopening, including after a checkpoint that failed at step 11 and a next one that crashed before step 8. `DeferredWritesTest` crashes a checkpoint at `CHECKPOINT_PAGES`, `CHECKPOINT_CATALOG` and `CHECKPOINT_JOURNAL` while another thread keeps committing, and checks that every acknowledged commit is there after reopening and that the counts match. -The checkpoint LSN is the position of the `Checkpoint` record itself, so the next recovery starts there and skips it. Triggers: +The checkpoint LSN is the end of `g`'s `Commit` record, not the position of the `Checkpoint` record. The next recovery starts there, replays the commits published after `g`, and skips the `Checkpoint` record when it reaches it. Triggers: | Trigger | Where | |---|---| | Every open, right after recovery | `StorageEngine` constructor | -| WAL or page writes grew by more than `checkpointWalBytes` (default 256 MiB) since the last checkpoint, or more than 1 048 576 reference counts changed; checked every 500 ms | `StorageEngine.maintain` | +| The write buffer holds more than half of `writeBufferBytes` (default 64 MiB); WAL or page writes grew by more than `checkpointWalBytes` (default 256 MiB) since the last checkpoint; or more than 1 048 576 reference counts changed. Checked every `maintenanceInterval` (default 500 ms), or sooner when a commit finds the write buffer full | `StorageEngine.maintain` | | Compaction, before choosing victims and again after moving pages, before deleting segments | `StorageEngine.compact` | +| `verifyReferences()`, before it compares the counts | `StorageEngine.verifyReferences` | | Clean shutdown | `StorageEngine.close` | | On demand | `StorageEngine.checkpoint()`, HQL `CHECKPOINT` | @@ -299,20 +336,20 @@ After each completed checkpoint, `StorageEngine.timedCheckpoint` logs `checkpoin 2. Arms a fault injector that throws `CrashPoint.SimulatedCrash` (an `Error`) the first time the chosen point is reached. 3. Begins a transaction that creates 300 more nodes and inserts each at position 0, then commits: the commit must throw `SimulatedCrash`. Crash points up to `COMMIT_APPEND` throw on the committing thread inside `append`; `DATA_SYNC`, `WAL_SYNC` and `CATALOG_PUBLISH` throw on the committer leading the batch (here the committing thread itself), break the pipeline, and are rethrown to every waiting committer by `GroupCommitter.failIfFailed`. 4. `halt()`s the engine (no checkpoint, files closed as after a process crash; the OS page cache is preserved). -5. Reopens and asserts the edge has 200 or 500 members, never anything else; exactly `500` iff `point >= COMMIT_APPEND`; the node count equals the member count; every member has degree 1 (the reverse index agrees with the forward index). It then writes again, reopens, and checks that the post-crash write survived (recovery left the log appendable). +5. Reopens, checks the reference counts (`verifyReferences`), and asserts the edge has 200 or 500 members, never anything else; exactly `500` iff `point >= COMMIT_APPEND`; the node count equals the member count; every member has degree 1 (the reverse index agrees with the forward index). It then writes again, reopens, and checks that the post-crash write survived (recovery left the log appendable). | Crash point | Thread | On disk at crash | `PAGE_IMAGES` | `PAGE_REFERENCES` | |---|---|---|---|---| | `PAGE` | committer | nothing for this commit | 200 (prior) | 200 (prior) | -| `WAL_APPEND` | committer | `Begin`, page records, `Root`s, `Feed`; no `Commit` | 200, counted as unfinished | 200, counted as unfinished | -| `DATA_WRITE` | committer | as above plus data pages (orphaned) | 200 | 200 | -| `COMMIT_APPEND` | committer | `Commit` frame written, not synced | 500 (new) | 500, `PageRef`s verify | -| `DATA_SYNC` | commit leader | data synced (refs mode only), WAL not synced | 500 | 500 | +| `WAL_APPEND` | committer | `Begin`, `Changes`, `Feed`; no `Commit` | 200, counted as unfinished | 200, counted as unfinished | +| `DATA_WRITE` | committer | as above; every tree stays under 4,096 changes, so the commit wrote no pages | 200 | 200 | +| `COMMIT_APPEND` | committer | `Commit` frame written, not synced | 500 (new), replayed from `Changes` | 500, same | +| `DATA_SYNC` | commit leader | no data sync (the batch wrote no pages), WAL not synced | 500 | 500 | | `WAL_SYNC` | commit leader | WAL synced, not published | 500 | 500 | | `CATALOG_PUBLISH` | commit leader | feed appended (not forced), published to `current` | 500, feed frame kept or re-appended from the `Feed` record | 500, same | `COMMIT_APPEND` is the instructive row: the client received an error, yet the commit is recovered. This is the standard "commit outcome unknown" case of every database; clients that must know use `TxnOptions.withRequestId(...)` and retry, and the retry returns `DUPLICATE` with the original generation. -`recoveryStopsAtTheFirstCommitWhosePagesAreMissing` covers the power-loss reordering that a process crash cannot produce. With `PAGE_REFERENCES` it commits `durable`, reopens, commits `lost-1` and `lost-2`, halts, and truncates the last data segment to one byte short of its size before `lost-2`, so `lost-1`'s last page fails its checksum. Recovery must report `discardedCommits >= 1`, keep `durable`, drop `lost-2` (which itself has intact pages but follows a broken commit), and accept new writes that survive another reopen. +`recoveryStopsAtTheFirstCommitWhosePagesAreMissing` covers the power-loss reordering that a process crash cannot produce. With `PAGE_REFERENCES` it commits `durable`, reopens, commits `lost-1` and `lost-2`, each of which creates a node and bulk-loads a 20,000-member edge (the load spills pages, and the commit writes the rest of the tree because it grew by more than 4,096 entries), halts, and truncates the last data segment to one byte short of its size before `lost-2`, so `lost-1`'s last page fails its checksum. Recovery must report `discardedCommits >= 1`, keep `durable`, drop `lost-2` (which itself has intact pages but follows a broken commit), and accept new writes that survive another reopen. `concurrentCommitsShareDurabilityBarriers` runs 400 concurrent writers and checks that all 400 commits are visible and that `averageGroupCommit >= 1`. From 3ee6927a1cf79e7cded4a81cd8e18a1311e7c883 Mon Sep 17 00:00:00 2001 From: venkat1701 Date: Mon, 12 Oct 2026 13:03:44 +0530 Subject: [PATCH 9/9] docs(bench): note that crash recovery got slower with logged commits Recovery replays each commit's changed keys since format 7, and the laptop runs put it at 171 ms against 90 ms on format 6 and 123 ms for HyperGraphDB. The published tables don't include recover, so the losses list was the only place to say so. --- docs/benchmarks.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 6775ad3..441aff0 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -270,6 +270,10 @@ not what drives HStore's footprint: images are live, and the background pass runs after every `checkpoint_wal_mb` of writes ([maintenance.md](storage/maintenance.md)). Raising `compaction_live_ratio` trades write amplification for space. +* **Crash recovery since storage format 7 (`recover`, not in the tables above).** Recovery now replays each + commit's changed keys onto the trees of the last checkpoint, which takes more CPU than pointing the roots at + pages that were already written. On a busy laptop at scale 1, the median went from 90 ms on format 6 to + 171 ms, against 123 ms for HyperGraphDB. That hasn't been rerun on an idle machine yet. ### Inline member trees