You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tinkerpop.apache.org by ke...@apache.org on 2026/10/06 17:25:37 UTC
(tinkerpop) branch csr created (now 74bc6a55db)
This is an automated email from the ASF dual-hosted git repository.
kenhuuu pushed a change to branch csr
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
at 74bc6a55db test run
This branch includes the following new commits:
new 74bc6a55db test run
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(tinkerpop) 01/01: test run
Posted by ke...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
kenhuuu pushed a commit to branch csr
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit 74bc6a55db62c5d93c8076ec3f032af2df21d2dd
Author: Ken Hu <10...@users.noreply.github.com>
AuthorDate: Tue Oct 6 10:25:12 2026 -0700
test run
---
.../tinkerpop/gremlin/jsr223/CoreImports.java | 19 +
.../gremlin/structure/snapshot/CsrSnapshot.java | 1090 ++++++++++++++++++
.../structure/snapshot/build/BuildOptions.java | 133 +++
.../structure/snapshot/build/BuildStats.java | 77 ++
.../snapshot/build/ExternalIndexSorter.java | 227 ++++
.../snapshot/build/HeapSnapshotBuilder.java | 681 +++++++++++
.../structure/snapshot/build/PropertySpool.java | 163 +++
.../snapshot/build/PropertySpoolReader.java | 93 ++
.../structure/snapshot/build/SnapshotBuilder.java | 34 +
.../structure/snapshot/build/SpoolRecord.java | 111 ++
.../snapshot/build/StreamingSnapshotBuilder.java | 999 +++++++++++++++++
.../structure/snapshot/format/BuildDirectory.java | 264 +++++
.../structure/snapshot/format/ColumnCursor.java | 77 ++
.../structure/snapshot/format/ColumnEncoding.java | 46 +
.../structure/snapshot/format/ColumnReader.java | 492 ++++++++
.../structure/snapshot/format/ColumnStats.java | 152 +++
.../structure/snapshot/format/ColumnWriter.java | 437 ++++++++
.../structure/snapshot/format/IdentifierIndex.java | 299 +++++
.../snapshot/format/IdentifierIndexWriter.java | 199 ++++
.../structure/snapshot/format/LabelCounts.java | 48 +
.../structure/snapshot/format/LabelDictionary.java | 178 +++
.../structure/snapshot/format/LabelListWriter.java | 122 ++
.../structure/snapshot/format/Manifest.java | 500 +++++++++
.../structure/snapshot/format/ManifestIO.java | 351 ++++++
.../structure/snapshot/format/MappedSegment.java | 377 +++++++
.../structure/snapshot/format/OwnerCursor.java | 124 ++
.../structure/snapshot/format/OwnerWriter.java | 183 +++
.../structure/snapshot/format/SegmentHeader.java | 239 ++++
.../structure/snapshot/format/SegmentPaths.java | 150 +++
.../structure/snapshot/format/SegmentReader.java | 239 ++++
.../structure/snapshot/format/SegmentWriter.java | 240 ++++
.../structure/snapshot/format/SnapshotLayout.java | 42 +
.../structure/snapshot/format/ValueCodec.java | 534 +++++++++
.../structure/snapshot/format/ValueType.java | 108 ++
.../format/VertexPropertyColumnWriter.java | 146 +++
.../gremlin/structure/snapshot/graph/CsrEdge.java | 215 ++++
.../structure/snapshot/graph/CsrElement.java | 39 +
.../structure/snapshot/graph/CsrFeatures.java | 487 ++++++++
.../gremlin/structure/snapshot/graph/CsrGraph.java | 423 +++++++
.../structure/snapshot/graph/CsrMetaProperty.java | 97 ++
.../structure/snapshot/graph/CsrProperty.java | 102 ++
.../structure/snapshot/graph/CsrVertex.java | 315 ++++++
.../snapshot/graph/CsrVertexProperty.java | 194 ++++
.../structure/snapshot/process/CsrNative.java | 60 +
.../structure/snapshot/process/CsrSuperStep.java | 365 ++++++
.../snapshot/process/exec/AbstractCsrOperator.java | 172 +++
.../structure/snapshot/process/exec/Batch.java | 228 ++++
.../snapshot/process/exec/BatchSupplier.java | 34 +
.../snapshot/process/exec/CoreOperators.java | 60 +
.../snapshot/process/exec/CsrExecutionContext.java | 203 ++++
.../process/exec/CsrMemoryBudgetException.java | 81 ++
.../snapshot/process/exec/CsrOperator.java | 88 ++
.../snapshot/process/exec/CsrOperatorFactory.java | 173 +++
.../snapshot/process/exec/CsrPipeline.java | 152 +++
.../snapshot/process/exec/CsrRuntime.java | 138 +++
.../snapshot/process/exec/CsrSettings.java | 49 +
.../snapshot/process/exec/DenseFrontier.java | 147 +++
.../snapshot/process/exec/FeedSupplier.java | 50 +
.../structure/snapshot/process/exec/Frontier.java | 116 ++
.../structure/snapshot/process/exec/Lane.java | 91 ++
.../snapshot/process/exec/Materializer.java | 70 ++
.../snapshot/process/exec/MemoryBudget.java | 169 +++
.../snapshot/process/exec/OpEstimate.java | 42 +
.../snapshot/process/exec/OperatorRegistrar.java | 29 +
.../snapshot/process/exec/OperatorSpec.java | 52 +
.../snapshot/process/exec/OperatorStats.java | 109 ++
.../snapshot/process/exec/Rehydrator.java | 111 ++
.../snapshot/process/exec/ScratchSpace.java | 134 +++
.../snapshot/process/exec/SparseFrontier.java | 215 ++++
.../snapshot/process/exec/StubOperators.java | 88 ++
.../structure/snapshot/process/op/CsrOp.java | 122 ++
.../structure/snapshot/process/op/CsrPlan.java | 156 +++
.../structure/snapshot/process/op/Keys.java | 97 ++
.../gremlin/structure/snapshot/process/op/Ops.java | 961 ++++++++++++++++
.../structure/snapshot/process/op/Preds.java | 130 +++
.../structure/snapshot/process/op/Sources.java | 126 +++
.../structure/snapshot/process/op/Terminals.java | 277 +++++
.../snapshot/process/op/branch/BranchOperator.java | 94 ++
.../process/op/branch/BranchOperators.java | 54 +
.../snapshot/process/op/branch/ChildRun.java | 119 ++
.../snapshot/process/op/branch/ChoiceKey.java | 138 +++
.../snapshot/process/op/branch/ChooseOperator.java | 121 ++
.../snapshot/process/op/branch/ChunkSupplier.java | 72 ++
.../process/op/branch/ConnectiveOperator.java | 95 ++
.../snapshot/process/op/branch/DegreeFilter.java | 64 ++
.../process/op/branch/DegreeFilterOperator.java | 127 +++
.../snapshot/process/op/branch/ExistenceTest.java | 198 ++++
.../snapshot/process/op/branch/ExistsOperator.java | 63 ++
.../snapshot/process/op/branch/OrdinalBits.java | 85 ++
.../process/op/branch/PerEntryOperator.java | 249 +++++
.../snapshot/process/op/branch/Plans.java | 92 ++
.../snapshot/process/op/branch/ResultCache.java | 152 +++
.../process/op/branch/RoutingOperator.java | 236 ++++
.../snapshot/process/op/branch/UnionOperator.java | 67 ++
.../snapshot/process/op/filter/EntryPredicate.java | 34 +
.../snapshot/process/op/filter/FilterOperator.java | 66 ++
.../process/op/filter/FilterOperators.java | 41 +
.../process/op/filter/PredicateCompiler.java | 213 ++++
.../snapshot/process/op/nav/DedupOperator.java | 279 +++++
.../snapshot/process/op/nav/EndpointOperator.java | 64 ++
.../process/op/nav/EntryStreamOperator.java | 113 ++
.../snapshot/process/op/nav/ExpandOperator.java | 202 ++++
.../snapshot/process/op/nav/LookupOperator.java | 84 ++
.../snapshot/process/op/nav/MergeOperator.java | 155 +++
.../snapshot/process/op/nav/MidScanOperator.java | 90 ++
.../process/op/nav/NavigationOperators.java | 101 ++
.../snapshot/process/op/nav/OtherVOperator.java | 57 +
.../snapshot/process/op/nav/RangeOperator.java | 100 ++
.../snapshot/process/op/nav/ScanOperator.java | 70 ++
.../snapshot/process/op/repeat/LoopStack.java | 94 ++
.../snapshot/process/op/repeat/LoopsOperator.java | 57 +
.../snapshot/process/op/repeat/RepeatOperator.java | 524 +++++++++
.../process/op/repeat/RepeatOperators.java | 36 +
.../snapshot/process/op/spill/ByteSource.java | 78 ++
.../structure/snapshot/process/op/spill/Bytes.java | 91 ++
.../snapshot/process/op/spill/EntryCodec.java | 113 ++
.../snapshot/process/op/spill/KeyBytes.java | 65 ++
.../snapshot/process/op/spill/KeyCodec.java | 343 ++++++
.../snapshot/process/op/spill/KeyEvaluator.java | 177 +++
.../snapshot/process/op/spill/Merger.java | 148 +++
.../snapshot/process/op/spill/ResultMap.java | 61 +
.../snapshot/process/op/spill/SpillBuffer.java | 277 +++++
.../snapshot/process/op/spill/SpillCountTable.java | 232 ++++
.../process/op/spill/SpillDedupOperator.java | 314 ++++++
.../snapshot/process/op/spill/SpillFrontier.java | 430 +++++++
.../process/op/spill/SpillGroupCountOperator.java | 73 ++
.../process/op/spill/SpillGroupCountState.java | 328 ++++++
.../op/spill/SpillGroupCountWriterOperator.java | 89 ++
.../process/op/spill/SpillGroupOperator.java | 74 ++
.../snapshot/process/op/spill/SpillGroupState.java | 595 ++++++++++
.../process/op/spill/SpillGroupWriterOperator.java | 88 ++
.../snapshot/process/op/spill/SpillOperators.java | 46 +
.../snapshot/process/op/spill/SpillPartitions.java | 107 ++
.../process/op/spill/SpillPersistentDedup.java | 559 +++++++++
.../snapshot/process/op/spill/SpillReserve.java | 98 ++
.../snapshot/process/op/spill/SpillRun.java | 235 ++++
.../process/op/spill/SpillSortOperator.java | 255 +++++
.../snapshot/process/op/spill/SpillSupport.java | 97 ++
.../op/terminal/AggregateWriterOperator.java | 176 +++
.../process/op/terminal/DrainOperator.java | 55 +
.../process/op/terminal/GroupAccumulator.java | 375 +++++++
.../snapshot/process/op/terminal/GroupIndex.java | 384 +++++++
.../process/op/terminal/GroupTerminalOperator.java | 76 ++
.../process/op/terminal/GroupWriterOperator.java | 94 ++
.../snapshot/process/op/terminal/KeyReader.java | 161 +++
.../snapshot/process/op/terminal/OrderedRows.java | 135 +++
.../process/op/terminal/ReducerOperator.java | 71 ++
.../snapshot/process/op/terminal/Reducers.java | 451 ++++++++
.../process/op/terminal/SideEffectOperators.java | 37 +
.../snapshot/process/op/terminal/SortOperator.java | 95 ++
.../process/op/terminal/TerminalOperators.java | 47 +
.../snapshot/process/op/terminal/TopKOperator.java | 155 +++
.../snapshot/process/op/value/FanOutOperator.java | 106 ++
.../snapshot/process/op/value/KeyRanges.java | 139 +++
.../snapshot/process/op/value/LabelsOperator.java | 87 ++
.../snapshot/process/op/value/MapOperators.java | 260 +++++
.../snapshot/process/op/value/MapOps.java | 168 +++
.../process/op/value/ProjectKeyReader.java | 164 +++
.../snapshot/process/op/value/PropsOperator.java | 109 ++
.../snapshot/process/op/value/SimpleOperators.java | 269 +++++
.../snapshot/process/op/value/ValueOperators.java | 90 ++
.../snapshot/process/strategy/Analysis.java | 198 ++++
.../process/strategy/CsrNativeStrategy.java | 218 ++++
.../snapshot/process/strategy/PlanBuilder.java | 146 +++
.../snapshot/process/strategy/Planner.java | 340 ++++++
.../snapshot/process/strategy/Reject.java | 30 +
.../snapshot/process/strategy/StepCompiler.java | 1119 ++++++++++++++++++
.../structure/snapshot/spi/EdgeScanOrder.java | 37 +
.../gremlin/structure/snapshot/spi/EdgeSink.java | 28 +
.../structure/snapshot/spi/PropertySource.java | 28 +
.../structure/snapshot/spi/PropertyVisitor.java | 33 +
.../structure/snapshot/spi/SnapshotSource.java | 89 ++
.../structure/snapshot/spi/SourceDefaults.java | 93 ++
.../structure/snapshot/spi/SourceVersion.java | 61 +
.../spi/UnsupportedSnapshotDataException.java | 34 +
.../snapshot/spi/VertexPropertySource.java | 28 +
.../snapshot/spi/VertexPropertyVisitor.java | 38 +
.../gremlin/structure/snapshot/spi/VertexSink.java | 37 +
spark-gremlin/src/test/csr-memory/csrbench.py | 1070 ++++++++++++++++++
spark-gremlin/src/test/csr-memory/matrix.json | 194 ++++
.../src/test/csr-memory/smoke-matrix.json | 64 ++
.../tinkerpop/gremlin/spark/csr/CsrMemoryRun.java | 1182 ++++++++++++++++++++
.../tinkerpop/gremlin/spark/csr/csr-queries.txt | 81 ++
.../jsr223/TinkerGraphGremlinPlugin.java | 4 +
.../tinkergraph/structure/GryoSnapshotSource.java | 258 +++++
.../structure/TinkerGraphSnapshotSource.java | 241 ++++
.../structure/CsrBenchmarkDataGenerator.java | 348 ++++++
.../structure/CsrSnapshotBuildBenchmark.java | 777 +++++++++++++
188 files changed, 36000 insertions(+)
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/jsr223/CoreImports.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/jsr223/CoreImports.java
index 4fe50ed633..d398fbf0e1 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/jsr223/CoreImports.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/jsr223/CoreImports.java
@@ -142,6 +142,15 @@ import org.apache.tinkerpop.gremlin.structure.io.gryo.GryoMapper;
import org.apache.tinkerpop.gremlin.structure.io.gryo.GryoReader;
import org.apache.tinkerpop.gremlin.structure.io.gryo.GryoVersion;
import org.apache.tinkerpop.gremlin.structure.io.gryo.GryoWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.CsrSnapshot;
+import org.apache.tinkerpop.gremlin.structure.snapshot.build.BuildOptions;
+import org.apache.tinkerpop.gremlin.structure.snapshot.build.HeapSnapshotBuilder;
+import org.apache.tinkerpop.gremlin.structure.snapshot.build.StreamingSnapshotBuilder;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SnapshotLayout;
+import org.apache.tinkerpop.gremlin.structure.snapshot.graph.CsrGraph;
+import org.apache.tinkerpop.gremlin.structure.snapshot.process.CsrNative;
+import org.apache.tinkerpop.gremlin.structure.snapshot.process.exec.CsrMemoryBudgetException;
+import org.apache.tinkerpop.gremlin.structure.snapshot.process.strategy.CsrNativeStrategy;
import org.apache.tinkerpop.gremlin.structure.util.ElementHelper;
import org.apache.tinkerpop.gremlin.structure.util.GraphFactory;
import org.apache.tinkerpop.gremlin.structure.util.empty.EmptyGraph;
@@ -217,6 +226,16 @@ public final class CoreImports {
// remote
CLASS_IMPORTS.add(RemoteConnection.class);
CLASS_IMPORTS.add(EmptyGraph.class);
+ // snapshot
+ CLASS_IMPORTS.add(CsrGraph.class);
+ CLASS_IMPORTS.add(CsrSnapshot.class);
+ CLASS_IMPORTS.add(StreamingSnapshotBuilder.class);
+ CLASS_IMPORTS.add(HeapSnapshotBuilder.class);
+ CLASS_IMPORTS.add(BuildOptions.class);
+ CLASS_IMPORTS.add(SnapshotLayout.class);
+ CLASS_IMPORTS.add(CsrNative.class);
+ CLASS_IMPORTS.add(CsrNativeStrategy.class);
+ CLASS_IMPORTS.add(CsrMemoryBudgetException.class);
// io
CLASS_IMPORTS.add(GraphReader.class);
CLASS_IMPORTS.add(GraphWriter.class);
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/CsrSnapshot.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/CsrSnapshot.java
new file mode 100644
index 0000000000..f5d6725361
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/CsrSnapshot.java
@@ -0,0 +1,1090 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ColumnReader;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.IdentifierIndex;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.LabelDictionary;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.Manifest;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ManifestIO;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.MappedSegment;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.OwnerCursor;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentPaths;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SnapshotLayout;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ValueType;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.SourceDefaults;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * Read-only access to a completed snapshot bundle through memory-mapped segments. The bundle's
+ * {@code manifest.json} is read, every segment it lists is mapped, and each segment's header is checked against the
+ * manifest entry. Adjacency is exposed by position: the out-adjacency of vertex {@code v} occupies positions
+ * {@code [outStart(v), outEnd(v))} and {@link #outNeighbor(long)} and {@link #outEdge(long)} give the neighbor and edge
+ * ordinals at a position, likewise for the incoming direction. Identifiers, labels, endpoints and properties are
+ * keyed by vertex or edge ordinal.
+ * <p/>
+ * Which methods are available depends on the {@link SnapshotLayout}. A method that needs a segment the layout does not
+ * publish throws {@link UnsupportedOperationException}: adjacency edge ordinals, identifiers, labels, endpoints and
+ * identifier lookup need {@code IDENTITY} or {@code FULL}, properties need {@code FULL} and edge identifier lookup
+ * also needs the edge identifier index.
+ * <p/>
+ * Ordinal arguments are not range-checked beyond the bounds check of the underlying segment, which throws
+ * {@link IndexOutOfBoundsException}. Instances may be used from several threads. Nothing may be called after
+ * {@link #close()}.
+ * <p/>
+ * Vertices may have several labels ({@link #vertexLabelCount(int)}), and a vertex property key may hold several vertex
+ * properties per vertex. Vertex properties of one key are addressed by their vertex-property ordinal, see
+ * {@link #vertexPropertyStart(int, int)}; meta-properties, graph variables and the source defaults are also exposed.
+ */
+public final class CsrSnapshot implements AutoCloseable {
+
+ private final Path root;
+ private final Manifest manifest;
+ private final SnapshotLayout layout;
+ private final int vertexCount;
+ private final int edgeCount;
+ private final boolean outEdgesImplicit;
+
+ // every mapped segment, by bundle-relative path
+ private final Map<String, MappedSegment> segments;
+
+ private final MappedSegment outOffsets;
+ private final MappedSegment outNeighbors;
+ private final MappedSegment outEdges;
+ private final MappedSegment inOffsets;
+ private final MappedSegment inNeighbors;
+ private final MappedSegment inEdges;
+
+ private final String[] vertexLabels;
+ private final String[] edgeLabels;
+ private final MappedSegment vertexLabelCodes;
+ private final boolean multiLabel;
+ private final MappedSegment vertexLabelOffsets;
+ private final MappedSegment vertexLabelCodeList;
+ private final MappedSegment edgeLabelCodes;
+ private final MappedSegment edgeOutVertices;
+ private final MappedSegment edgeInVertices;
+
+ private final ColumnReader vertexIds;
+ private final ColumnReader edgeIds;
+ private final IdentifierIndex vertexIdIndex;
+ private final IdentifierIndex edgeIdIndex;
+
+ // statistics: elements per label code, from the manifest or computed at open; null when the layout has no labels
+ private final long[] vertexLabelCounts;
+ private final long[] edgeLabelCounts;
+
+ private final Map<String, Integer> vertexLabelByName = new HashMap<>();
+ private final Map<String, Integer> edgeLabelByName = new HashMap<>();
+
+ private final List<String> vertexKeys;
+ private final List<String> edgeKeys;
+ private final Map<String, Integer> vertexKeyCodes = new HashMap<>();
+ private final Map<String, Integer> edgeKeyCodes = new HashMap<>();
+ private final List<ColumnReader> vertexProperties = new ArrayList<>();
+ private final List<ColumnReader> vertexPropertyIds = new ArrayList<>();
+ private final List<ColumnReader> edgeProperties = new ArrayList<>();
+
+ private final SourceDefaults sourceDefaults;
+ // per vertex key code: multi layout flag, owner files (null in the single layout, ordinals null for dense owners),
+ // and the meta-property key codes with their columns, both in ascending meta key code order
+ private final boolean[] multiKeys;
+ private final List<MappedSegment> ownerOffsets = new ArrayList<>();
+ private final List<MappedSegment> ownerOrdinals = new ArrayList<>();
+ private final List<int[]> metaCodes = new ArrayList<>();
+ private final List<ColumnReader[]> metaColumns = new ArrayList<>();
+ private final List<String> metaKeys = new ArrayList<>();
+ private final Map<String, Integer> metaKeyCodes = new HashMap<>();
+ private final Map<String, Object> variables = new LinkedHashMap<>();
+ private final Map<String, Object> variablesView = Collections.unmodifiableMap(variables);
+
+ private CsrSnapshot(final Path root, final Manifest manifest, final Map<String, MappedSegment> segments) {
+ this.root = root;
+ this.manifest = manifest;
+ this.segments = segments;
+ this.layout = manifest.getLayout();
+ this.outEdgesImplicit = manifest.isOutEdgesImplicit();
+
+ final Function<String, MappedSegment> opener = path -> {
+ final MappedSegment segment = segments.get(path);
+ if (segment == null) {
+ throw new IllegalStateException("Segment " + path + " is not listed in the manifest of " + root);
+ }
+ return segment;
+ };
+
+ final long vertices = manifest.getVertexCount();
+ final long edges = manifest.getEdgeCount();
+ if (vertices < 0 || vertices >= Integer.MAX_VALUE || edges < 0 || edges >= Integer.MAX_VALUE) {
+ throw new IllegalStateException("Counts of " + vertices + " vertices and " + edges
+ + " edges exceed the 32-bit ordinals of this reader");
+ }
+ vertexCount = (int) vertices;
+ edgeCount = (int) edges;
+ final SourceDefaults defaults = manifest.getSourceDefaults();
+ sourceDefaults = defaults == null ? SourceDefaults.DEFAULT : defaults;
+ multiLabel = layout != SnapshotLayout.TOPOLOGY
+ && manifest.getVertexLabelLayout() == Manifest.VertexLabelLayout.MULTI;
+
+ // adjacency, present in every layout
+ outOffsets = require(opener, SegmentPaths.OUT_OFFSETS, 8, vertices + 1);
+ outNeighbors = require(opener, SegmentPaths.OUT_NEIGHBORS, 4, edges);
+ inOffsets = require(opener, SegmentPaths.IN_OFFSETS, 8, vertices + 1);
+ inNeighbors = require(opener, SegmentPaths.IN_NEIGHBORS, 4, edges);
+ checkOffsets(outOffsets, edges);
+ checkOffsets(inOffsets, edges);
+
+ if (layout == SnapshotLayout.TOPOLOGY) {
+ outEdges = null;
+ inEdges = null;
+ vertexLabels = null;
+ edgeLabels = null;
+ vertexLabelCounts = null;
+ edgeLabelCounts = null;
+ vertexLabelCodes = null;
+ vertexLabelOffsets = null;
+ vertexLabelCodeList = null;
+ multiKeys = new boolean[0];
+ edgeLabelCodes = null;
+ edgeOutVertices = null;
+ edgeInVertices = null;
+ vertexIds = null;
+ edgeIds = null;
+ vertexIdIndex = null;
+ edgeIdIndex = null;
+ vertexKeys = Collections.emptyList();
+ edgeKeys = Collections.emptyList();
+ return;
+ }
+
+ outEdges = outEdgesImplicit ? null : require(opener, SegmentPaths.OUT_EDGES, 4, edges);
+ inEdges = require(opener, SegmentPaths.IN_EDGES, 4, edges);
+
+ vertexLabels = manifest.getVertexLabels().toArray(new String[0]);
+ edgeLabels = manifest.getEdgeLabels().toArray(new String[0]);
+ for (int code = 0; code < vertexLabels.length; code++) vertexLabelByName.putIfAbsent(vertexLabels[code], code);
+ for (int code = 0; code < edgeLabels.length; code++) edgeLabelByName.putIfAbsent(edgeLabels[code], code);
+ if (multiLabel) {
+ vertexLabelCodes = null;
+ vertexLabelOffsets = require(opener, SegmentPaths.VERTEX_LABEL_OFFSETS, 8, vertices + 1);
+ if (vertexLabelOffsets.getLong(0) != 0) {
+ throw new IllegalStateException("Label offsets of " + root + " do not start at zero");
+ }
+ vertexLabelCodeList = require(opener, SegmentPaths.VERTEX_LABEL_CODES,
+ LabelDictionary.codeWidth(vertexLabels.length), vertexLabelOffsets.getLong(vertices));
+ } else {
+ vertexLabelCodes = require(opener, SegmentPaths.VERTEX_LABELS,
+ LabelDictionary.codeWidth(vertexLabels.length), vertices);
+ vertexLabelOffsets = null;
+ vertexLabelCodeList = null;
+ }
+ edgeLabelCodes = require(opener, SegmentPaths.EDGE_LABELS, LabelDictionary.codeWidth(edgeLabels.length),
+ edges);
+ edgeOutVertices = require(opener, SegmentPaths.EDGE_OUT_VERTICES, 4, edges);
+ edgeInVertices = require(opener, SegmentPaths.EDGE_IN_VERTICES, 4, edges);
+
+ vertexLabelCounts = computeVertexLabelCounts(manifest.getVertexLabelCounts());
+ edgeLabelCounts = computeEdgeLabelCounts(manifest.getEdgeLabelCounts());
+
+ if (manifest.getVertexIds() == null || manifest.getEdgeIds() == null) {
+ throw new IllegalStateException("The manifest of a " + layout + " snapshot lacks an identifier column");
+ }
+ vertexIds = ColumnReader.open(opener, SegmentPaths.VERTEX_IDS_DIR, vertices, manifest.getVertexIds());
+ edgeIds = ColumnReader.open(opener, SegmentPaths.EDGE_IDS_DIR, edges, manifest.getEdgeIds());
+ if (vertexIds.presentCount() != vertices || edgeIds.presentCount() != edges) {
+ throw new IllegalStateException("Identifier columns of " + root + " do not cover every element");
+ }
+ vertexIdIndex = openIndex(opener, SegmentPaths.VERTEX_ID_INDEX_KEYS, SegmentPaths.VERTEX_ID_INDEX_ORDINALS,
+ vertices, manifest.getVertexIds(), manifest.isVertexIdIndexExact(), vertexIds);
+ edgeIdIndex = manifest.isEdgeIdIndex()
+ ? openIndex(opener, SegmentPaths.EDGE_ID_INDEX_KEYS, SegmentPaths.EDGE_ID_INDEX_ORDINALS, edges,
+ manifest.getEdgeIds(), manifest.isEdgeIdIndexExact(), edgeIds)
+ : null;
+
+ if (layout == SnapshotLayout.FULL) {
+ vertexKeys = Collections.unmodifiableList(new ArrayList<>(manifest.getVertexKeys()));
+ edgeKeys = Collections.unmodifiableList(new ArrayList<>(manifest.getEdgeKeys()));
+ if (manifest.getVertexProperties().size() != vertexKeys.size()
+ || manifest.getVertexPropertyIds().size() != vertexKeys.size()
+ || manifest.getEdgeProperties().size() != edgeKeys.size()) {
+ throw new IllegalStateException("Property column lists of " + root
+ + " are not parallel to the key dictionaries");
+ }
+ metaKeys.addAll(manifest.getMetaKeys());
+ for (int code = 0; code < metaKeys.size(); code++) {
+ if (metaKeyCodes.put(metaKeys.get(code), code) != null) {
+ throw new IllegalStateException("Duplicate meta-property key " + metaKeys.get(code));
+ }
+ }
+ final List<Manifest.VertexKeyInfo> keyInfos = manifest.getVertexKeyInfos();
+ if (keyInfos.size() != vertexKeys.size()) {
+ throw new IllegalStateException("Vertex key layouts of " + root
+ + " are not parallel to the key dictionary");
+ }
+ multiKeys = new boolean[vertexKeys.size()];
+ for (int code = 0; code < vertexKeys.size(); code++) {
+ if (vertexKeyCodes.put(vertexKeys.get(code), code) != null) {
+ throw new IllegalStateException("Duplicate vertex property key " + vertexKeys.get(code));
+ }
+ final Manifest.VertexKeyInfo keyInfo = keyInfos.get(code);
+ final boolean multi = keyInfo.layout() == Manifest.PropertyLayout.MULTI;
+ multiKeys[code] = multi;
+ final ColumnReader values = ColumnReader.open(opener, SegmentPaths.vertexPropertyDir(code),
+ multi ? keyInfo.propertyCount() : vertices, manifest.getVertexProperties().get(code));
+ if (values.presentCount() != keyInfo.propertyCount()
+ || (multi && values.entryCount() != keyInfo.propertyCount())) {
+ throw new IllegalStateException("Vertex property key " + vertexKeys.get(code) + " has "
+ + values.presentCount() + " entries but the manifest says " + keyInfo.propertyCount()
+ + " vertex properties");
+ }
+ vertexProperties.add(values);
+ vertexPropertyIds.add(ColumnReader.openCompanion(opener, SegmentPaths.vertexPropertyIdsDir(code),
+ values, manifest.getVertexPropertyIds().get(code)));
+ if (multi) {
+ if (keyInfo.ownerEncoding() == null) {
+ throw new IllegalStateException("Multi vertex property key " + vertexKeys.get(code)
+ + " has no owner encoding");
+ }
+ final boolean sparse = keyInfo.ownerEncoding() == Manifest.OwnerEncoding.SPARSE;
+ MappedSegment ordinals = null;
+ long owners = vertices;
+ if (sparse) {
+ ordinals = opener.apply(SegmentPaths.vertexPropertyOwnerOrdinals(code));
+ if (ordinals.valueWidth() != 4) {
+ throw new IllegalStateException("Segment " + ordinals.path() + " has width "
+ + ordinals.valueWidth() + ", expected 4");
+ }
+ owners = ordinals.count();
+ }
+ final MappedSegment offsets = require(opener, SegmentPaths.vertexPropertyOwnerOffsets(code), 8,
+ owners + 1);
+ if (offsets.getLong(0) != 0 || offsets.getLong(owners) != keyInfo.propertyCount()) {
+ throw new IllegalStateException("Owner offsets " + offsets.path() + " do not span "
+ + keyInfo.propertyCount() + " vertex properties");
+ }
+ ownerOffsets.add(offsets);
+ ownerOrdinals.add(ordinals);
+ } else {
+ ownerOffsets.add(null);
+ ownerOrdinals.add(null);
+ }
+ final int[] codes = new int[keyInfo.metaColumns().size()];
+ final ColumnReader[] columns = new ColumnReader[codes.length];
+ int n = 0;
+ for (final Map.Entry<Integer, Manifest.ColumnInfo> meta : keyInfo.metaColumns().entrySet()) {
+ final int metaCode = meta.getKey();
+ if (metaCode < 0 || metaCode >= metaKeys.size()) {
+ throw new IllegalStateException("Meta-property key code " + metaCode + " of vertex property "
+ + vertexKeys.get(code) + " is outside the dictionary");
+ }
+ codes[n] = metaCode;
+ columns[n++] = ColumnReader.open(opener, SegmentPaths.vertexPropertyMetaDir(code, metaCode),
+ values.entryCount(), meta.getValue());
+ }
+ metaCodes.add(codes);
+ metaColumns.add(columns);
+ }
+ for (int code = 0; code < edgeKeys.size(); code++) {
+ if (edgeKeyCodes.put(edgeKeys.get(code), code) != null) {
+ throw new IllegalStateException("Duplicate edge property key " + edgeKeys.get(code));
+ }
+ edgeProperties.add(ColumnReader.open(opener, SegmentPaths.edgePropertyDir(code), edges,
+ manifest.getEdgeProperties().get(code)));
+ }
+ final List<String> variableKeys = manifest.getVariableKeys();
+ if (!variableKeys.isEmpty()) {
+ if (manifest.getVariables() == null) {
+ throw new IllegalStateException("The manifest of " + root + " lists variables but no column");
+ }
+ final ColumnReader column = ColumnReader.open(opener, SegmentPaths.VARIABLES_DIR,
+ variableKeys.size(), manifest.getVariables());
+ for (int code = 0; code < variableKeys.size(); code++) {
+ final String key = variableKeys.get(code);
+ if (variables.containsKey(key) || !column.isPresent(code)) {
+ throw new IllegalStateException("Variable " + key + " is duplicated or has no entry");
+ }
+ variables.put(key, column.get(code));
+ }
+ }
+ } else {
+ vertexKeys = Collections.emptyList();
+ edgeKeys = Collections.emptyList();
+ multiKeys = new boolean[0];
+ }
+ }
+
+ /**
+ * Opens the bundle in a directory.
+ *
+ * @param root the bundle root, containing {@code manifest.json}
+ * @param verifyChecksums whether to recompute the checksum of every segment, which reads the whole bundle. The
+ * checksums stored in the segment headers are always compared with the manifest.
+ * @throws java.io.UncheckedIOException if a file cannot be read or mapped or a segment header is malformed
+ * @throws IllegalStateException if the manifest and the segments disagree
+ */
+ public static CsrSnapshot open(final Path root, final boolean verifyChecksums) {
+ Objects.requireNonNull(root);
+ final Manifest manifest = ManifestIO.read(root.resolve(SegmentPaths.MANIFEST));
+ if (manifest.getFormatVersion() != Manifest.FORMAT_VERSION) {
+ throw new IllegalStateException("Unsupported snapshot format version " + manifest.getFormatVersion()
+ + " in " + root);
+ }
+ if (manifest.getLayout() == null) {
+ throw new IllegalStateException("The manifest of " + root + " has no layout");
+ }
+ if (manifest.getVertexOrdinalWidth() != Integer.BYTES || manifest.getEdgeOrdinalWidth() != Integer.BYTES) {
+ throw new IllegalStateException("Unsupported ordinal widths " + manifest.getVertexOrdinalWidth() + " and "
+ + manifest.getEdgeOrdinalWidth() + " in " + root);
+ }
+
+ final Map<String, MappedSegment> segments = new LinkedHashMap<>();
+ try {
+ final Function<String, MappedSegment> opener = ColumnReader.readOnlyOpener(root);
+ for (final Manifest.SegmentInfo info : manifest.getSegments()) {
+ final MappedSegment segment = opener.apply(info.path());
+ segments.put(info.path(), segment);
+ if (segment.valueWidth() != info.width() || segment.count() != info.count()
+ || segment.checksum() != info.checksum()) {
+ throw new IllegalStateException("Segment " + info.path() + " has width " + segment.valueWidth()
+ + ", count " + segment.count() + " and checksum " + segment.checksum()
+ + " but the manifest says " + info.width() + ", " + info.count() + " and "
+ + info.checksum());
+ }
+ }
+ final CsrSnapshot snapshot = new CsrSnapshot(root, manifest, segments);
+ if (verifyChecksums) snapshot.verifyChecksums();
+ return snapshot;
+ } catch (RuntimeException e) {
+ for (final MappedSegment segment : segments.values()) segment.close();
+ throw e;
+ }
+ }
+
+ private MappedSegment require(final Function<String, MappedSegment> opener, final String path, final int width,
+ final long count) {
+ final MappedSegment segment = opener.apply(path);
+ if (segment.valueWidth() != width || segment.count() != count) {
+ throw new IllegalStateException("Segment " + path + " has width " + segment.valueWidth() + " and count "
+ + segment.count() + ", expected width " + width + " and count " + count);
+ }
+ return segment;
+ }
+
+ // offsets must start at zero and end at the number of adjacency entries
+ private static void checkOffsets(final MappedSegment offsets, final long edges) {
+ if (offsets.getLong(0) != 0 || offsets.getLong(offsets.count() - 1) != edges) {
+ throw new IllegalStateException("Offsets " + offsets.path() + " do not span " + edges + " entries");
+ }
+ }
+
+ // the manifest's vertex label counts when they match the dictionary, otherwise one pass over the label codes
+ private long[] computeVertexLabelCounts(final long[] stored) {
+ if (stored != null && stored.length == vertexLabels.length) return stored;
+ final long[] counts = new long[vertexLabels.length];
+ final MappedSegment codes = multiLabel ? vertexLabelCodeList : vertexLabelCodes;
+ final long entries = codes.count();
+ for (long i = 0; i < entries; i++) counts[checkedCode(LabelDictionary.readCode(codes, i), counts.length)]++;
+ return counts;
+ }
+
+ private long[] computeEdgeLabelCounts(final long[] stored) {
+ if (stored != null && stored.length == edgeLabels.length) return stored;
+ final long[] counts = new long[edgeLabels.length];
+ for (long i = 0; i < edgeCount; i++) {
+ counts[checkedCode(LabelDictionary.readCode(edgeLabelCodes, i), counts.length)]++;
+ }
+ return counts;
+ }
+
+ private static int checkedCode(final int code, final int labels) {
+ if (code < 0 || code >= labels) {
+ throw new IllegalStateException("Label code " + code + " is outside the dictionary of " + labels
+ + " labels");
+ }
+ return code;
+ }
+
+ private IdentifierIndex openIndex(final Function<String, MappedSegment> opener, final String keysPath,
+ final String ordinalsPath, final long count, final Manifest.ColumnInfo idInfo,
+ final boolean manifestExact, final ColumnReader ids) {
+ final MappedSegment keys = require(opener, keysPath, 8, count);
+ final MappedSegment ordinals = require(opener, ordinalsPath, 4, count);
+ final ValueType exactType = IdentifierIndex.exactType(idInfo);
+ if (manifestExact != (exactType != null)) {
+ throw new IllegalStateException("The exact-index flag of " + keysPath + " disagrees with the identifier "
+ + "column types " + idInfo.valueTypes());
+ }
+ return IdentifierIndex.of(keys, ordinals, exactType, ids);
+ }
+
+ // ---------------------------------------------------------------- bundle
+
+ public Path root() {
+ return root;
+ }
+
+ public Manifest manifest() {
+ return manifest;
+ }
+
+ public SnapshotLayout layout() {
+ return layout;
+ }
+
+ /**
+ * The {@code sourceId} of the source version the snapshot was built from.
+ */
+ public String sourceId() {
+ return manifest.getSourceId();
+ }
+
+ /**
+ * The {@code version} of the source version the snapshot was built from.
+ */
+ public String sourceVersion() {
+ return manifest.getSourceVersion();
+ }
+
+ public int vertexCount() {
+ return vertexCount;
+ }
+
+ public int edgeCount() {
+ return edgeCount;
+ }
+
+ /**
+ * Whether out-adjacency positions are edge ordinals, so that {@link #outEdge(long)} is the identity.
+ */
+ public boolean isOutEdgesImplicit() {
+ return outEdgesImplicit;
+ }
+
+ /**
+ * Whether edge identifiers can be looked up with {@link #edgeOrdinal(Object)}.
+ */
+ public boolean hasEdgeIdIndex() {
+ return edgeIdIndex != null;
+ }
+
+ /**
+ * Recomputes the checksum of every segment and compares it with the header.
+ *
+ * @throws java.io.UncheckedIOException if a segment does not match its header
+ */
+ public void verifyChecksums() {
+ for (final MappedSegment segment : segments.values()) segment.verifyChecksum();
+ }
+
+ // ---------------------------------------------------------------- dictionaries
+
+ /**
+ * The vertex label dictionary; the code of a label is its index.
+ */
+ public List<String> vertexLabels() {
+ requireIdentity("vertex labels");
+ return manifest.getVertexLabels();
+ }
+
+ public List<String> edgeLabels() {
+ requireIdentity("edge labels");
+ return manifest.getEdgeLabels();
+ }
+
+ /**
+ * The vertex property keys; the code of a key is its index in this list, so the key at index {@code i} is the key
+ * of the column at code {@code i}. Empty unless the layout is {@code FULL}.
+ */
+ public List<String> vertexPropertyKeys() {
+ return vertexKeys;
+ }
+
+ /**
+ * The edge property keys; the code of a key is its index in this list. Empty unless the layout is {@code FULL}.
+ */
+ public List<String> edgePropertyKeys() {
+ return edgeKeys;
+ }
+
+ // ---------------------------------------------------------------- adjacency
+
+ /**
+ * The first out-adjacency position of the vertex.
+ */
+ public long outStart(final int vertex) {
+ return outOffsets.getLong(vertex);
+ }
+
+ /**
+ * One past the last out-adjacency position of the vertex.
+ */
+ public long outEnd(final int vertex) {
+ return outOffsets.getLong(vertex + 1L);
+ }
+
+ /**
+ * The ordinal of the vertex at the far end of the out-adjacency entry at {@code position}.
+ */
+ public int outNeighbor(final long position) {
+ return outNeighbors.getInt(position);
+ }
+
+ /**
+ * The edge ordinal of the out-adjacency entry at {@code position}, which is {@code position} itself when
+ * out-edges are implicit.
+ *
+ * @throws UnsupportedOperationException if the layout publishes no adjacency edge ordinals
+ */
+ public int outEdge(final long position) {
+ if (outEdgesImplicit) {
+ Objects.checkIndex(position, edgeCount);
+ return (int) position;
+ }
+ if (outEdges == null) throw unsupported("out-edge ordinals");
+ return outEdges.getInt(position);
+ }
+
+ public long inStart(final int vertex) {
+ return inOffsets.getLong(vertex);
+ }
+
+ public long inEnd(final int vertex) {
+ return inOffsets.getLong(vertex + 1L);
+ }
+
+ /**
+ * The ordinal of the vertex at the far end of the in-adjacency entry at {@code position}.
+ */
+ public int inNeighbor(final long position) {
+ return inNeighbors.getInt(position);
+ }
+
+ /**
+ * The edge ordinal of the in-adjacency entry at {@code position}.
+ *
+ * @throws UnsupportedOperationException if the layout publishes no adjacency edge ordinals
+ */
+ public int inEdge(final long position) {
+ if (inEdges == null) throw unsupported("in-edge ordinals");
+ return inEdges.getInt(position);
+ }
+
+ public int outDegree(final int vertex) {
+ return (int) (outEnd(vertex) - outStart(vertex));
+ }
+
+ public int inDegree(final int vertex) {
+ return (int) (inEnd(vertex) - inStart(vertex));
+ }
+
+ // ---------------------------------------------------------------- statistics and code-level access
+
+ /**
+ * The number of vertices with each vertex label, indexed by label code; a multi-label vertex counts once for each
+ * of its labels. Read from the manifest when it carries the statistic and computed at open otherwise. The array is
+ * shared and must not be modified.
+ *
+ * @throws UnsupportedOperationException if the layout is {@code TOPOLOGY}
+ */
+ public long[] vertexLabelCounts() {
+ requireIdentity("vertex labels");
+ return vertexLabelCounts;
+ }
+
+ /**
+ * The number of edges with each edge label, indexed by label code; see {@link #vertexLabelCounts()}.
+ *
+ * @throws UnsupportedOperationException if the layout is {@code TOPOLOGY}
+ */
+ public long[] edgeLabelCounts() {
+ requireIdentity("edge labels");
+ return edgeLabelCounts;
+ }
+
+ /**
+ * The code of the vertex property key, or -1 if the snapshot has no such key. Always -1 unless the layout is
+ * {@code FULL}.
+ */
+ public int vertexKeyCodeOf(final String key) {
+ final Integer code = vertexKeyCodes.get(key);
+ return code == null ? -1 : code;
+ }
+
+ /**
+ * The code of the edge property key, or -1 if the snapshot has no such key. Always -1 unless the layout is
+ * {@code FULL}.
+ */
+ public int edgeKeyCodeOf(final String key) {
+ final Integer code = edgeKeyCodes.get(key);
+ return code == null ? -1 : code;
+ }
+
+ /**
+ * The value column of the vertex property key with the given code; see {@link #vertexProperty(String)} for how
+ * to address it.
+ *
+ * @throws UnsupportedOperationException if the layout is not {@code FULL}
+ */
+ public ColumnReader vertexPropertyColumn(final int keyCode) {
+ requireFull("vertex properties");
+ return vertexProperties.get(keyCode);
+ }
+
+ /**
+ * The vertex-property identifier column of the key with the given code.
+ *
+ * @throws UnsupportedOperationException if the layout is not {@code FULL}
+ */
+ public ColumnReader vertexPropertyIdColumn(final int keyCode) {
+ requireFull("vertex-property identifiers");
+ return vertexPropertyIds.get(keyCode);
+ }
+
+ /**
+ * The value column of the edge property key with the given code, addressed by edge ordinal.
+ *
+ * @throws UnsupportedOperationException if the layout is not {@code FULL}
+ */
+ public ColumnReader edgePropertyColumn(final int keyCode) {
+ requireFull("edge properties");
+ return edgeProperties.get(keyCode);
+ }
+
+ /**
+ * The meta-property column for the vertex property key and meta key, or null if no vertex property of the key has
+ * the meta key. Its entries are addressed by vertex-property ordinal.
+ *
+ * @throws UnsupportedOperationException if the layout is not {@code FULL}
+ */
+ public ColumnReader metaPropertyColumn(final int keyCode, final int metaKeyCode) {
+ return metaColumn(keyCode, metaKeyCode);
+ }
+
+ /**
+ * A cursor over the vertex-property ordinal ranges of the key for ascending vertex ordinals, for both the single
+ * and the multi layout. Use one cursor per scan.
+ *
+ * @throws UnsupportedOperationException if the layout is not {@code FULL}
+ */
+ public OwnerCursor vertexPropertyCursor(final int keyCode) {
+ requireFull("vertex properties");
+ if (!multiKeys[keyCode]) return OwnerCursor.ofColumn(vertexProperties.get(keyCode));
+ return OwnerCursor.ofOwners(ownerOffsets.get(keyCode), ownerOrdinals.get(keyCode), vertexCount);
+ }
+
+ // ---------------------------------------------------------------- vertices
+
+ /**
+ * The identifier of the vertex.
+ */
+ public Object vertexId(final int vertex) {
+ requireIdentity("vertex identifiers");
+ return vertexIds.get(vertex);
+ }
+
+ /**
+ * The first label of the vertex in source order, or the empty string when it has none.
+ */
+ public String vertexLabel(final int vertex) {
+ final int code = vertexLabelCode(vertex);
+ return code < 0 ? "" : label(vertexLabels, code);
+ }
+
+ /**
+ * Whether vertices may have zero or several labels, that is whether the snapshot uses the multi-label layout.
+ */
+ public boolean isMultiLabel() {
+ return multiLabel;
+ }
+
+ /**
+ * The number of labels of the vertex, which is always 1 unless {@link #isMultiLabel()}.
+ */
+ public int vertexLabelCount(final int vertex) {
+ requireIdentity("vertex labels");
+ if (!multiLabel) {
+ Objects.checkIndex(vertex, vertexCount);
+ return 1;
+ }
+ return (int) (vertexLabelOffsets.getLong(vertex + 1L) - vertexLabelOffsets.getLong(vertex));
+ }
+
+ /**
+ * The code of the {@code index}th label of the vertex in source order, an index into {@link #vertexLabels()}.
+ *
+ * @throws IndexOutOfBoundsException if the index is not in {@code [0, vertexLabelCount(vertex))}
+ */
+ public int vertexLabelCodeAt(final int vertex, final int index) {
+ requireIdentity("vertex labels");
+ if (!multiLabel) {
+ Objects.checkIndex(index, 1);
+ return LabelDictionary.readCode(vertexLabelCodes, vertex);
+ }
+ final long start = vertexLabelOffsets.getLong(vertex);
+ Objects.checkIndex(index, vertexLabelOffsets.getLong(vertex + 1L) - start);
+ return LabelDictionary.readCode(vertexLabelCodeList, start + index);
+ }
+
+ /**
+ * The ordinal of the vertex with the given identifier, or -1 if there is none.
+ */
+ public int vertexOrdinal(final Object id) {
+ requireIdentity("vertex identifier lookup");
+ return vertexIdIndex.lookup(id);
+ }
+
+ /**
+ * Ordinal of the vertex with this id, or -1. Integral ids (Byte/Short/Integer/Long) match any stored integral id
+ * with the same numeric value when the vertex id index is exact; all other ids match on encoded bytes like
+ * {@link #vertexOrdinal(Object)}.
+ */
+ public int vertexOrdinalCoerced(final Object id) {
+ requireIdentity("vertex identifier lookup");
+ return coerced(vertexIdIndex, id);
+ }
+
+ /**
+ * The raw code of the vertex's first label in source order, an index into {@link #vertexLabels()}, or -1 when the
+ * vertex has no label.
+ */
+ public int vertexLabelCode(final int vertex) {
+ requireIdentity("vertex labels");
+ if (!multiLabel) return LabelDictionary.readCode(vertexLabelCodes, vertex);
+ final long start = vertexLabelOffsets.getLong(vertex);
+ if (vertexLabelOffsets.getLong(vertex + 1L) == start) return -1;
+ return LabelDictionary.readCode(vertexLabelCodeList, start);
+ }
+
+ /**
+ * The code of the vertex label, or -1 if it is not in the dictionary.
+ */
+ public int vertexLabelCodeOf(final String label) {
+ requireIdentity("vertex labels");
+ final Integer code = vertexLabelByName.get(label);
+ return code == null ? -1 : code;
+ }
+
+ // ---------------------------------------------------------------- edges
+
+ public Object edgeId(final int edge) {
+ requireIdentity("edge identifiers");
+ return edgeIds.get(edge);
+ }
+
+ public String edgeLabel(final int edge) {
+ requireIdentity("edge labels");
+ return label(edgeLabels, LabelDictionary.readCode(edgeLabelCodes, edge));
+ }
+
+ /**
+ * The ordinal of the edge with the given identifier, or -1 if there is none.
+ *
+ * @throws UnsupportedOperationException if the snapshot has no edge identifier index
+ */
+ public int edgeOrdinal(final Object id) {
+ if (edgeIdIndex == null) throw unsupported("edge identifier lookup");
+ return edgeIdIndex.lookup(id);
+ }
+
+ /**
+ * Same as {@link #vertexOrdinalCoerced(Object)} for edges.
+ *
+ * @throws UnsupportedOperationException if the snapshot has no edge identifier index
+ */
+ public int edgeOrdinalCoerced(final Object id) {
+ if (edgeIdIndex == null) throw unsupported("edge identifier lookup");
+ return coerced(edgeIdIndex, id);
+ }
+
+ /**
+ * The raw label code of the edge, an index into {@link #edgeLabels()}.
+ */
+ public int edgeLabelCode(final int edge) {
+ requireIdentity("edge labels");
+ return LabelDictionary.readCode(edgeLabelCodes, edge);
+ }
+
+ /**
+ * The code of the edge label, or -1 if it is not in the dictionary.
+ */
+ public int edgeLabelCodeOf(final String label) {
+ requireIdentity("edge labels");
+ final Integer code = edgeLabelByName.get(label);
+ return code == null ? -1 : code;
+ }
+
+ /**
+ * The ordinal of the edge's out-vertex.
+ */
+ public int edgeOut(final int edge) {
+ requireIdentity("edge endpoints");
+ return edgeOutVertices.getInt(edge);
+ }
+
+ /**
+ * The ordinal of the edge's in-vertex.
+ */
+ public int edgeIn(final int edge) {
+ requireIdentity("edge endpoints");
+ return edgeInVertices.getInt(edge);
+ }
+
+ // ---------------------------------------------------------------- properties
+
+ /**
+ * The column of the vertex property key, or null if the snapshot has no such key. The column of a key that is
+ * {@link #isMultiProperty(int) multi} is addressed by vertex-property ordinal rather than by vertex ordinal, so
+ * this is only meaningful for other keys; {@link #vertexPropertyValue(int, long)} works for both.
+ *
+ * @throws UnsupportedOperationException if the layout is not {@code FULL}
+ */
+ public ColumnReader vertexProperty(final String key) {
+ requireFull("vertex properties");
+ final Integer code = vertexKeyCodes.get(key);
+ return code == null ? null : vertexProperties.get(code);
+ }
+
+ /**
+ * The column of vertex-property identifiers for the key, or null if the snapshot has no such key. It has an entry
+ * exactly where {@link #vertexProperty(String)} has a value.
+ *
+ * @throws UnsupportedOperationException if the layout is not {@code FULL}
+ */
+ public ColumnReader vertexPropertyId(final String key) {
+ requireFull("vertex-property identifiers");
+ final Integer code = vertexKeyCodes.get(key);
+ return code == null ? null : vertexPropertyIds.get(code);
+ }
+
+ /**
+ * The column of the edge property key, or null if the snapshot has no such key.
+ *
+ * @throws UnsupportedOperationException if the layout is not {@code FULL}
+ */
+ public ColumnReader edgeProperty(final String key) {
+ requireFull("edge properties");
+ final Integer code = edgeKeyCodes.get(key);
+ return code == null ? null : edgeProperties.get(code);
+ }
+
+ /**
+ * The first vertex-property ordinal of the vertex for the key. Together with
+ * {@link #vertexPropertyEnd(int, int)} this is the half-open range {@code [start, end)} of the vertex properties
+ * the vertex has for the key, empty when it has none. In the single layout the ordinal is the entry index of the
+ * key's column, so the range is {@code [e, e + 1)} when the vertex has a value.
+ *
+ * @throws UnsupportedOperationException if the layout is not {@code FULL}
+ */
+ public long vertexPropertyStart(final int keyCode, final int vertex) {
+ requireFull("vertex properties");
+ if (!multiKeys[keyCode]) {
+ final long entry = vertexProperties.get(keyCode).entryIndex(vertex);
+ return entry < 0 ? 0 : entry;
+ }
+ final MappedSegment offsets = ownerOffsets.get(keyCode);
+ final MappedSegment ordinals = ownerOrdinals.get(keyCode);
+ if (ordinals == null) {
+ Objects.checkIndex(vertex, vertexCount);
+ return offsets.getLong(vertex);
+ }
+ final long owner = ownerIndex(ordinals, vertex);
+ return owner < 0 ? 0 : offsets.getLong(owner);
+ }
+
+ /**
+ * One past the last vertex-property ordinal of the vertex for the key; see
+ * {@link #vertexPropertyStart(int, int)}.
+ */
+ public long vertexPropertyEnd(final int keyCode, final int vertex) {
+ requireFull("vertex properties");
+ if (!multiKeys[keyCode]) {
+ final long entry = vertexProperties.get(keyCode).entryIndex(vertex);
+ return entry < 0 ? 0 : entry + 1;
+ }
+ final MappedSegment offsets = ownerOffsets.get(keyCode);
+ final MappedSegment ordinals = ownerOrdinals.get(keyCode);
+ if (ordinals == null) {
+ Objects.checkIndex(vertex, vertexCount);
+ return offsets.getLong(vertex + 1L);
+ }
+ final long owner = ownerIndex(ordinals, vertex);
+ return owner < 0 ? 0 : offsets.getLong(owner + 1);
+ }
+
+ /**
+ * The value of a vertex property, or null if the value is null.
+ *
+ * @param vertexProperty an ordinal in a range given by {@link #vertexPropertyStart(int, int)}
+ */
+ public Object vertexPropertyValue(final int keyCode, final long vertexProperty) {
+ requireFull("vertex properties");
+ return vertexProperties.get(keyCode).getAt(vertexProperty);
+ }
+
+ /**
+ * The identifier of a vertex property.
+ *
+ * @param vertexProperty an ordinal in a range given by {@link #vertexPropertyStart(int, int)}
+ */
+ public Object vertexPropertyIdentifier(final int keyCode, final long vertexProperty) {
+ requireFull("vertex-property identifiers");
+ return vertexPropertyIds.get(keyCode).getAt(vertexProperty);
+ }
+
+ /**
+ * Whether some vertex has more than one vertex property for the key.
+ */
+ public boolean isMultiProperty(final int keyCode) {
+ requireFull("vertex properties");
+ return multiKeys[keyCode];
+ }
+
+ /**
+ * The meta-property key dictionary; the code of a key is its index. Empty unless the layout is {@code FULL}.
+ */
+ public List<String> metaPropertyKeys() {
+ return Collections.unmodifiableList(metaKeys);
+ }
+
+ /**
+ * The code of the meta-property key, or -1 if no vertex property has it.
+ */
+ public int metaKeyCodeOf(final String metaKey) {
+ final Integer code = metaKeyCodes.get(metaKey);
+ return code == null ? -1 : code;
+ }
+
+ /**
+ * The meta-property key codes used on vertex properties of the key, in ascending order. The array is shared and
+ * must not be modified.
+ *
+ * @throws UnsupportedOperationException if the layout is not {@code FULL}
+ */
+ public int[] metaKeyCodes(final int keyCode) {
+ requireFull("meta-properties");
+ return metaCodes.get(keyCode);
+ }
+
+ /**
+ * Whether the vertex property has a meta-property with the key, which may have a null value.
+ */
+ public boolean hasMetaProperty(final int keyCode, final int metaKeyCode, final long vertexProperty) {
+ final ColumnReader column = metaColumn(keyCode, metaKeyCode);
+ return column != null && column.isPresent(vertexProperty);
+ }
+
+ /**
+ * The value of the vertex property's meta-property, or null when it has none or it is null; use
+ * {@link #hasMetaProperty(int, int, long)} to tell the two apart.
+ */
+ public Object metaPropertyValue(final int keyCode, final int metaKeyCode, final long vertexProperty) {
+ final ColumnReader column = metaColumn(keyCode, metaKeyCode);
+ return column == null ? null : column.get(vertexProperty);
+ }
+
+ /**
+ * The graph variables, decoded, in key-code order. Empty when the source had none or the layout is not
+ * {@code FULL}. The map is read-only.
+ */
+ public Map<String, Object> variables() {
+ return variablesView;
+ }
+
+ /**
+ * The defaults and cardinalities of the source graph, {@link SourceDefaults#DEFAULT} if it reported none.
+ */
+ public SourceDefaults sourceDefaults() {
+ return sourceDefaults;
+ }
+
+ // ---------------------------------------------------------------- lifecycle
+
+ /**
+ * Releases the segments. The mappings themselves are reclaimed by the garbage collector.
+ */
+ @Override
+ public void close() {
+ for (final MappedSegment segment : segments.values()) segment.close();
+ }
+
+ // ---------------------------------------------------------------- helpers
+
+ // the index of the vertex among the sorted owner ordinals, or -1
+ private static long ownerIndex(final MappedSegment ordinals, final int vertex) {
+ long lo = 0;
+ long hi = ordinals.count() - 1;
+ while (lo <= hi) {
+ final long mid = (lo + hi) >>> 1;
+ final int v = ordinals.getInt(mid);
+ if (v < vertex) {
+ lo = mid + 1;
+ } else if (v > vertex) {
+ hi = mid - 1;
+ } else {
+ return mid;
+ }
+ }
+ return -1;
+ }
+
+ private ColumnReader metaColumn(final int keyCode, final int metaKeyCode) {
+ requireFull("meta-properties");
+ final int index = Arrays.binarySearch(metaCodes.get(keyCode), metaKeyCode);
+ return index < 0 ? null : metaColumns.get(keyCode)[index];
+ }
+
+ private static int coerced(final IdentifierIndex index, final Object id) {
+ if (index.isExact() && (id instanceof Long || id instanceof Integer || id instanceof Short
+ || id instanceof Byte)) {
+ return index.lookupIntegral(((Number) id).longValue());
+ }
+ return index.lookup(id);
+ }
+
+ private static String label(final String[] dictionary, final int code) {
+ if (code < 0 || code >= dictionary.length) {
+ throw new IllegalStateException("Label code " + code + " is outside the dictionary of "
+ + dictionary.length + " labels");
+ }
+ return dictionary[code];
+ }
+
+ private void requireIdentity(final String what) {
+ if (layout == SnapshotLayout.TOPOLOGY) throw unsupported(what);
+ }
+
+ private void requireFull(final String what) {
+ if (layout != SnapshotLayout.FULL) throw unsupported(what);
+ }
+
+ private UnsupportedOperationException unsupported(final String what) {
+ return new UnsupportedOperationException("The " + layout + " snapshot in " + root + " does not include "
+ + what);
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/BuildOptions.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/BuildOptions.java
new file mode 100644
index 0000000000..e4122d6065
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/BuildOptions.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.build;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SnapshotLayout;
+
+import java.nio.file.Path;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Immutable options for a {@link SnapshotBuilder}. Use {@link #defaults()} or {@link #builder()}.
+ */
+public final class BuildOptions {
+
+ public static final long DEFAULT_MEMORY_BUDGET_BYTES = 256L * 1024 * 1024;
+
+ private final SnapshotLayout layout;
+ private final long memoryBudgetBytes;
+ private final Path scratchDirectory;
+ private final boolean groupedFastPath;
+ private final boolean edgeIdIndex;
+
+ private BuildOptions(final Builder builder) {
+ this.layout = builder.layout;
+ this.memoryBudgetBytes = builder.memoryBudgetBytes;
+ this.scratchDirectory = builder.scratchDirectory;
+ this.groupedFastPath = builder.groupedFastPath;
+ this.edgeIdIndex = builder.edgeIdIndex;
+ }
+
+ public static BuildOptions defaults() {
+ return new Builder().build();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Which segments are published. Defaults to {@link SnapshotLayout#FULL}.
+ */
+ public SnapshotLayout layout() {
+ return layout;
+ }
+
+ /**
+ * Bytes the streaming builder may use for sort chunks and buffers. Defaults to
+ * {@link #DEFAULT_MEMORY_BUDGET_BYTES}.
+ */
+ public long memoryBudgetBytes() {
+ return memoryBudgetBytes;
+ }
+
+ /**
+ * Directory for scratch files, or empty to use the temporary build directory.
+ */
+ public Optional<Path> scratchDirectory() {
+ return Optional.ofNullable(scratchDirectory);
+ }
+
+ /**
+ * Whether the builder may use the grouped-by-out-vertex fast path when the source reports
+ * {@code GROUPED_BY_OUT_VERTEX}. Defaults to true; false lets the generic path be measured against a grouped source.
+ */
+ public boolean groupedFastPath() {
+ return groupedFastPath;
+ }
+
+ /**
+ * Whether the edge identifier index is published for layouts that include identity. Defaults to true.
+ */
+ public boolean edgeIdIndex() {
+ return edgeIdIndex;
+ }
+
+ public static final class Builder {
+ private SnapshotLayout layout = SnapshotLayout.FULL;
+ private long memoryBudgetBytes = DEFAULT_MEMORY_BUDGET_BYTES;
+ private Path scratchDirectory;
+ private boolean groupedFastPath = true;
+ private boolean edgeIdIndex = true;
+
+ private Builder() {
+ }
+
+ public Builder layout(final SnapshotLayout layout) {
+ this.layout = Objects.requireNonNull(layout);
+ return this;
+ }
+
+ public Builder memoryBudgetBytes(final long memoryBudgetBytes) {
+ if (memoryBudgetBytes <= 0) throw new IllegalArgumentException("memoryBudgetBytes must be positive");
+ this.memoryBudgetBytes = memoryBudgetBytes;
+ return this;
+ }
+
+ public Builder scratchDirectory(final Path scratchDirectory) {
+ this.scratchDirectory = scratchDirectory;
+ return this;
+ }
+
+ public Builder groupedFastPath(final boolean groupedFastPath) {
+ this.groupedFastPath = groupedFastPath;
+ return this;
+ }
+
+ public Builder edgeIdIndex(final boolean edgeIdIndex) {
+ this.edgeIdIndex = edgeIdIndex;
+ return this;
+ }
+
+ public BuildOptions build() {
+ return new BuildOptions(this);
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/BuildStats.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/BuildStats.java
new file mode 100644
index 0000000000..4b77e03982
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/BuildStats.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.build;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * What a {@link SnapshotBuilder} measured while building: one entry per phase, and the published size of every
+ * segment.
+ */
+public final class BuildStats {
+
+ /**
+ * One build phase.
+ *
+ * @param name the phase name
+ * @param elapsedNanos time spent in the phase
+ * @param diskBytes bytes on disk under the build and scratch directories when the phase ended
+ */
+ public record Phase(String name, long elapsedNanos, long diskBytes) {
+ }
+
+ private final List<Phase> phases;
+ private final Map<String, Long> segmentBytes;
+
+ /**
+ * @param phases phases in execution order
+ * @param segmentBytes published size in bytes of each segment, keyed by relative path, in ascending path order
+ */
+ public BuildStats(final List<Phase> phases, final Map<String, Long> segmentBytes) {
+ this.phases = List.copyOf(phases);
+ this.segmentBytes = java.util.Collections.unmodifiableMap(new java.util.LinkedHashMap<>(segmentBytes));
+ }
+
+ public List<Phase> phases() {
+ return phases;
+ }
+
+ public Map<String, Long> segmentBytes() {
+ return segmentBytes;
+ }
+
+ /**
+ * The largest {@link Phase#diskBytes()} across all phases, or 0 if there were none.
+ */
+ public long peakDiskBytes() {
+ long peak = 0;
+ for (final Phase p : phases) peak = Math.max(peak, p.diskBytes());
+ return peak;
+ }
+
+ /**
+ * The sum of all published segment sizes.
+ */
+ public long totalSegmentBytes() {
+ long total = 0;
+ for (final long b : segmentBytes.values()) total += b;
+ return total;
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/ExternalIndexSorter.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/ExternalIndexSorter.java
new file mode 100644
index 0000000000..7ba98df048
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/ExternalIndexSorter.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.build;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.IdentifierIndex;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentReader;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentWriter;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Sorts the entries of an identifier-index spool into {@linkplain IdentifierIndex#compare index order} with bounded
+ * heap. The input is a width-8 segment of keys in which the position of a key is its ordinal. Chunks that fit the
+ * memory budget are sorted in heap and written to scratch as runs of {@code (key, ordinal)} pairs, and the runs are
+ * merged k-way. When there are more runs than the merge fan-in, which the budget bounds, intermediate passes merge
+ * groups of runs into longer ones first. An input that fits a single chunk is sorted and emitted without any run files.
+ */
+final class ExternalIndexSorter {
+
+ @FunctionalInterface
+ interface EntrySink {
+ void accept(long key, int ordinal);
+ }
+
+ // heap per entry while sorting a chunk: key and ordinal, and the copy that the stable sort needs
+ private static final int BYTES_PER_CHUNK_ENTRY = 24;
+ private static final int MAX_CHUNK_ENTRIES = 1 << 26;
+ private static final int MAX_FAN_IN = 1024;
+
+ private final Function<String, Path> scratch;
+ private final int chunkEntries;
+ private final int bufferBytes;
+ private final int fanIn;
+ private int runSerial;
+
+ /**
+ * @param scratch maps a scratch-relative path to a file, creating missing parent directories
+ * @param budgetBytes the memory the sort may use: half for a sort chunk and half for merge buffers
+ */
+ ExternalIndexSorter(final Function<String, Path> scratch, final long budgetBytes) {
+ this.scratch = scratch;
+ this.chunkEntries = (int) Math.max(16, Math.min(MAX_CHUNK_ENTRIES, budgetBytes / 2 / BYTES_PER_CHUNK_ENTRY));
+ this.bufferBytes = (int) Math.max(64, Math.min(SegmentReader.DEFAULT_BUFFER_BYTES, budgetBytes / 128));
+ this.fanIn = (int) Math.max(2, Math.min(MAX_FAN_IN, budgetBytes / 2 / bufferBytes));
+ }
+
+ /**
+ * Feeds every entry of the spool to the sink in index order.
+ *
+ * @param keysSpool a finished width-8 segment of keys; the ordinal of a key is its position
+ * @param name distinguishes the run files of different sorts
+ */
+ void sort(final Path keysSpool, final String name, final EntrySink sink) {
+ List<Path> runs = new ArrayList<>();
+ try {
+ try (SegmentReader in = SegmentReader.open(keysSpool, bufferBytes)) {
+ final long n = in.count();
+ if (n <= chunkEntries) {
+ final int size = (int) n;
+ final long[] keys = new long[size];
+ final int[] ordinals = new int[size];
+ for (int i = 0; i < size; i++) {
+ keys[i] = in.readLong();
+ ordinals[i] = i;
+ }
+ IdentifierIndex.sortEntries(keys, ordinals, size);
+ for (int i = 0; i < size; i++) sink.accept(keys[i], ordinals[i]);
+ return;
+ }
+ final long[] keys = new long[chunkEntries];
+ final int[] ordinals = new int[chunkEntries];
+ long base = 0;
+ while (base < n) {
+ final int size = (int) Math.min(chunkEntries, n - base);
+ for (int i = 0; i < size; i++) {
+ keys[i] = in.readLong();
+ ordinals[i] = (int) (base + i);
+ }
+ IdentifierIndex.sortEntries(keys, ordinals, size);
+ final Path run = newRun(name);
+ runs.add(run);
+ try (SegmentWriter out = SegmentWriter.create(run, 8, bufferBytes)) {
+ for (int i = 0; i < size; i++) {
+ out.writeLong(keys[i]);
+ out.writeLong(ordinals[i]);
+ }
+ }
+ base += size;
+ }
+ }
+
+ while (runs.size() > fanIn) {
+ final List<Path> next = new ArrayList<>();
+ for (int from = 0; from < runs.size(); from += fanIn) {
+ final List<Path> group = runs.subList(from, Math.min(from + fanIn, runs.size()));
+ if (group.size() == 1) {
+ next.add(group.get(0));
+ continue;
+ }
+ final Path merged = newRun(name);
+ next.add(merged);
+ try (SegmentWriter out = SegmentWriter.create(merged, 8, bufferBytes)) {
+ merge(group, (key, ordinal) -> {
+ out.writeLong(key);
+ out.writeLong(ordinal);
+ });
+ }
+ for (final Path p : group) delete(p);
+ }
+ runs = next;
+ }
+ merge(runs, sink);
+ } finally {
+ for (final Path p : runs) delete(p);
+ }
+ }
+
+ private Path newRun(final String name) {
+ return scratch.apply("sort/" + name + "-run-" + (runSerial++) + ".bin");
+ }
+
+ private static void delete(final Path path) {
+ try {
+ Files.deleteIfExists(path);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * A sequential cursor over one run.
+ */
+ private static final class Cursor implements AutoCloseable {
+ private final SegmentReader reader;
+ private long key;
+ private int ordinal;
+
+ Cursor(final Path path, final int bufferBytes) {
+ this.reader = SegmentReader.open(path, bufferBytes);
+ }
+
+ boolean advance() {
+ if (!reader.hasRemaining()) return false;
+ key = reader.readLong();
+ ordinal = (int) reader.readLong();
+ return true;
+ }
+
+ boolean before(final Cursor other) {
+ return IdentifierIndex.compare(key, ordinal, other.key, other.ordinal) < 0;
+ }
+
+ @Override
+ public void close() {
+ reader.close();
+ }
+ }
+
+ private void merge(final List<Path> runs, final EntrySink sink) {
+ final List<Cursor> cursors = new ArrayList<>(runs.size());
+ try {
+ final Cursor[] heap = new Cursor[runs.size()];
+ int size = 0;
+ for (final Path run : runs) {
+ final Cursor cursor = new Cursor(run, bufferBytes);
+ cursors.add(cursor);
+ if (cursor.advance()) heap[size++] = cursor;
+ }
+ for (int i = size / 2 - 1; i >= 0; i--) siftDown(heap, i, size);
+ while (size > 0) {
+ final Cursor top = heap[0];
+ sink.accept(top.key, top.ordinal);
+ if (top.advance()) {
+ siftDown(heap, 0, size);
+ } else {
+ heap[0] = heap[--size];
+ heap[size] = null;
+ if (size > 0) siftDown(heap, 0, size);
+ }
+ }
+ } finally {
+ for (final Cursor cursor : cursors) {
+ try {
+ cursor.close();
+ } catch (RuntimeException ignored) {
+ // the build is failing or the run is about to be deleted
+ }
+ }
+ }
+ }
+
+ private static void siftDown(final Cursor[] heap, final int start, final int size) {
+ int parent = start;
+ final Cursor moving = heap[parent];
+ while (true) {
+ int child = 2 * parent + 1;
+ if (child >= size) break;
+ if (child + 1 < size && heap[child + 1].before(heap[child])) child++;
+ if (!heap[child].before(moving)) break;
+ heap[parent] = heap[child];
+ parent = child;
+ }
+ heap[parent] = moving;
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/HeapSnapshotBuilder.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/HeapSnapshotBuilder.java
new file mode 100644
index 0000000000..6d8487b272
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/HeapSnapshotBuilder.java
@@ -0,0 +1,681 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.build;
+
+import com.carrotsearch.hppc.IntArrayList;
+import com.carrotsearch.hppc.LongArrayList;
+import com.carrotsearch.hppc.ObjectIntHashMap;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.BuildDirectory;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ColumnReader;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ColumnStats;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ColumnWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.IdentifierIndex;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.IdentifierIndexWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.LabelCounts;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.LabelDictionary;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.LabelListWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.Manifest;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.OwnerWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentPaths;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SnapshotLayout;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ValueCodec;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ValueType;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.VertexPropertyColumnWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.EdgeScanOrder;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.PropertySource;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.PropertyVisitor;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.SnapshotSource;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.SourceVersion;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.UnsupportedSnapshotDataException;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.VertexPropertySource;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.VertexPropertyVisitor;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.OptionalLong;
+import java.util.TreeMap;
+
+/**
+ * The simple baseline snapshot builder and the reference output for the streaming builder. It reads the whole source
+ * into heap structures, builds both adjacency directions in heap and then writes every segment sequentially through
+ * the shared column, index and segment writers of the {@code format} package.
+ * <p/>
+ * The heap use is proportional to the size of the graph, in particular to the number of properties, because the
+ * identifiers and property values are retained as objects until they are written. The rules that decide the bytes of
+ * the output are:
+ * <ul>
+ * <li>vertex and edge ordinals follow the scan order;</li>
+ * <li>label codes and property-key codes are assigned in order of first appearance, by separate dictionaries for
+ * vertex labels, edge labels, vertex keys, edge keys, meta-property keys and variable keys. Only the layouts that
+ * publish them build them;</li>
+ * <li>vertex labels use the multi-label layout exactly when some vertex has a label count other than one, and a
+ * vertex key uses the multi-property layout exactly when some vertex has more than one vertex property for it;</li>
+ * <li>within one vertex, adjacency entries are in ascending edge ordinal;</li>
+ * <li>the out-edge segment is omitted, and the manifest flag {@code outEdgesImplicit} set, exactly when the
+ * source reports {@link EdgeScanOrder#GROUPED_BY_OUT_VERTEX}, whatever the value of
+ * {@link BuildOptions#groupedFastPath()}, which has no effect on this builder.</li>
+ * </ul>
+ * Property entries are only read for {@link SnapshotLayout#FULL}; the other layouts do not visit them. Instances are
+ * stateless and may be reused, but each build is single-threaded.
+ */
+public final class HeapSnapshotBuilder implements SnapshotBuilder {
+
+ // ordinals are 32-bit, so at most Integer.MAX_VALUE - 1 vertices or edges are accepted
+ private static final int MAX_ELEMENTS = Integer.MAX_VALUE - 1;
+
+ @Override
+ public BuildStats build(final SnapshotSource source, final Path target, final BuildOptions options) {
+ Objects.requireNonNull(source);
+ Objects.requireNonNull(target);
+ Objects.requireNonNull(options);
+ try (BuildDirectory dir = BuildDirectory.create(target, options.scratchDirectory().orElse(null))) {
+ return new Run(source, dir, options).run();
+ }
+ }
+
+ /**
+ * The state of one build.
+ */
+ private static final class Run {
+ private final SnapshotSource source;
+ private final BuildDirectory dir;
+ private final BuildOptions options;
+ private final SnapshotLayout layout;
+ private final boolean identity;
+ private final boolean full;
+ private final boolean grouped;
+ private final boolean edgeIdIndex;
+
+ private final List<BuildStats.Phase> phases = new ArrayList<>();
+ private final List<Manifest.SegmentInfo> segments = new ArrayList<>();
+ private long phaseStart;
+
+ // vertices
+ private int vertexCount;
+ private ObjectIntHashMap<Object> vertexOrdinals = new ObjectIntHashMap<>();
+ private final List<Object> vertexIds = new ArrayList<>();
+ private final LongArrayList vertexIndexKeys = new LongArrayList();
+ private final ColumnStats vertexIdStats = new ColumnStats();
+ private final LabelDictionary vertexLabels = new LabelDictionary();
+ private final IntArrayList vertexLabelCodes = new IntArrayList();
+ private final IntArrayList vertexLabelCounts = new IntArrayList();
+ private final LabelCounts vertexLabelHistogram = new LabelCounts();
+ private final LabelCounts edgeLabelHistogram = new LabelCounts();
+ private boolean multiLabels;
+ private final LabelDictionary vertexKeys = new LabelDictionary();
+ private final LabelDictionary metaKeys = new LabelDictionary();
+ private final List<PropertyEntries> vertexProperties = new ArrayList<>();
+
+ // edges
+ private int edgeCount;
+ private final IntArrayList edgeOut = new IntArrayList();
+ private final IntArrayList edgeIn = new IntArrayList();
+ private final List<Object> edgeIds = new ArrayList<>();
+ private final LongArrayList edgeIndexKeys = new LongArrayList();
+ private final ColumnStats edgeIdStats = new ColumnStats();
+ private final LabelDictionary edgeLabels = new LabelDictionary();
+ private final IntArrayList edgeLabelCodes = new IntArrayList();
+ private final LabelDictionary edgeKeys = new LabelDictionary();
+ private final List<PropertyEntries> edgeProperties = new ArrayList<>();
+ private int lastOutOrdinal = -1;
+ private Object lastOutId;
+ private int lastOutResolved;
+
+ // variables
+ private final LabelDictionary variableKeys = new LabelDictionary();
+ private final List<Object> variableValues = new ArrayList<>();
+ private final ColumnStats variableStats = new ColumnStats();
+
+ // adjacency
+ private long[] outOffsets;
+ private long[] inOffsets;
+ private int[] outNeighbors;
+ private int[] outEdges;
+ private int[] inNeighbors;
+ private int[] inEdges;
+
+ private final Manifest manifest = new Manifest();
+
+ Run(final SnapshotSource source, final BuildDirectory dir, final BuildOptions options) {
+ this.source = source;
+ this.dir = dir;
+ this.options = options;
+ this.layout = options.layout();
+ this.identity = layout != SnapshotLayout.TOPOLOGY;
+ this.full = layout == SnapshotLayout.FULL;
+ this.grouped = source.edgeScanOrder() == EdgeScanOrder.GROUPED_BY_OUT_VERTEX;
+ this.edgeIdIndex = identity && options.edgeIdIndex();
+ }
+
+ BuildStats run() {
+ phaseStart = System.nanoTime();
+ final SourceVersion version = source.version();
+
+ source.scanVertices(this::onVertex);
+ checkCount("vertex", vertexCount, source.vertexCount());
+ endPhase("scan-vertices");
+
+ source.scanEdges(this::onEdge);
+ checkCount("edge", edgeCount, source.edgeCount());
+ vertexOrdinals = null;
+ lastOutId = null;
+ endPhase("scan-edges");
+
+ if (full) {
+ source.scanVariables(variableVisitor);
+ endPhase("scan-variables");
+ }
+
+ buildAdjacency();
+ endPhase("build-adjacency");
+
+ manifest.setSourceId(version.sourceId());
+ manifest.setSourceVersion(version.version());
+ manifest.setLayout(layout);
+ manifest.setVertexCount(vertexCount);
+ manifest.setEdgeCount(edgeCount);
+ manifest.setOutEdgesImplicit(grouped);
+ manifest.setEdgeIdIndex(edgeIdIndex);
+ manifest.setSourceDefaults(source.defaults());
+
+ if (identity) {
+ writeVertices();
+ endPhase("write-vertices");
+ writeEdges();
+ endPhase("write-edges");
+ }
+ writeAdjacency();
+ endPhase("write-adjacency");
+ if (full) {
+ writeProperties();
+ endPhase("write-properties");
+ }
+
+ segments.sort(Comparator.comparing(Manifest.SegmentInfo::path));
+ manifest.setSegments(segments);
+ dir.publish(manifest);
+ return finishStats();
+ }
+
+ // ------------------------------------------------------------------ phases and stats
+
+ private void endPhase(final String name) {
+ final long elapsed = System.nanoTime() - phaseStart;
+ phases.add(new BuildStats.Phase(name, elapsed, dir.bytesOnDisk()));
+ phaseStart = System.nanoTime();
+ }
+
+ private BuildStats finishStats() {
+ final long elapsed = System.nanoTime() - phaseStart;
+ final Path root = dir.target();
+ final Map<String, Long> sizes = new LinkedHashMap<>();
+ long disk = 0;
+ try {
+ for (final Manifest.SegmentInfo s : segments) {
+ final long size = Files.size(root.resolve(s.path()));
+ sizes.put(s.path(), size);
+ disk += size;
+ }
+ disk += Files.size(root.resolve(SegmentPaths.MANIFEST));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ phases.add(new BuildStats.Phase("publish", elapsed, disk));
+ return new BuildStats(phases, sizes);
+ }
+
+ private static void checkCount(final String kind, final long emitted, final OptionalLong expected) {
+ if (expected.isPresent() && expected.getAsLong() != emitted) {
+ throw new IllegalStateException("The source announced " + expected.getAsLong() + " " + kind
+ + " elements but emitted " + emitted);
+ }
+ }
+
+ // ------------------------------------------------------------------ vertex scan
+
+ private int currentVertex;
+ private PropertyEntries currentEntries;
+ private final PropertyVisitor metaVisitor = (metaKey, metaValue) ->
+ currentEntries.meta(metaKeys.codeOf(metaKey)).add(currentEntries.ordinals.size() - 1, metaValue,
+ metaKey);
+ private final VertexPropertyVisitor vertexPropertyVisitor = (id, key, value, metaProperties) -> {
+ final int code = vertexKeys.codeOf(key);
+ while (vertexProperties.size() <= code) vertexProperties.add(new PropertyEntries(true));
+ currentEntries = vertexProperties.get(code);
+ currentEntries.add(currentVertex, id, value, "vertex property", key);
+ metaProperties.forEach(metaVisitor);
+ };
+
+ private void onVertex(final Object id, final List<String> labels, final VertexPropertySource props) {
+ if (vertexCount >= MAX_ELEMENTS) {
+ throw new UnsupportedSnapshotDataException("More than " + MAX_ELEMENTS + " vertices are not supported");
+ }
+ final int ordinal = vertexCount;
+ final ValueType idType = ValueCodec.requireIdentifierType(id, "vertex identifier", null);
+ final int existing = vertexOrdinals.indexOf(id);
+ if (vertexOrdinals.indexExists(existing)) {
+ throw new UnsupportedSnapshotDataException("Duplicate vertex identifier " + id + " ("
+ + id.getClass().getSimpleName() + ") at ordinals " + vertexOrdinals.indexGet(existing)
+ + " and " + ordinal);
+ }
+ vertexOrdinals.indexInsert(existing, id, ordinal);
+ if (identity) {
+ vertexIdStats.add(idType);
+ vertexIds.add(id);
+ vertexIndexKeys.add(IdentifierIndex.keyOf(id));
+ for (final String label : labels) {
+ final int code = vertexLabels.codeOf(label);
+ vertexLabelCodes.add(code);
+ vertexLabelHistogram.increment(code);
+ }
+ vertexLabelCounts.add(labels.size());
+ if (labels.size() != 1) multiLabels = true;
+ }
+ if (full) {
+ currentVertex = ordinal;
+ props.forEach(vertexPropertyVisitor);
+ }
+ vertexCount++;
+ }
+
+ // ------------------------------------------------------------------ edge scan
+
+ private int currentEdge;
+ private final PropertyVisitor edgePropertyVisitor = (key, value) -> {
+ final int code = edgeKeys.codeOf(key);
+ while (edgeProperties.size() <= code) edgeProperties.add(new PropertyEntries(false));
+ edgeProperties.get(code).add(currentEdge, null, value, "edge property", key);
+ };
+
+ private void onEdge(final Object id, final String label, final Object outVertexId, final Object inVertexId,
+ final PropertySource props) {
+ if (edgeCount >= MAX_ELEMENTS) {
+ throw new UnsupportedSnapshotDataException("More than " + MAX_ELEMENTS + " edges are not supported");
+ }
+ final int ordinal = edgeCount;
+ final int out;
+ if (lastOutId != null && lastOutId.equals(outVertexId)) {
+ out = lastOutResolved;
+ } else {
+ out = resolve(outVertexId, "out", ordinal);
+ lastOutId = outVertexId;
+ lastOutResolved = out;
+ }
+ final int in = resolve(inVertexId, "in", ordinal);
+ if (grouped && out < lastOutOrdinal) {
+ throw new IllegalStateException("The source reports edges grouped by out-vertex, but edge " + ordinal
+ + " has out-vertex ordinal " + out + " after " + lastOutOrdinal);
+ }
+ lastOutOrdinal = out;
+ edgeOut.add(out);
+ edgeIn.add(in);
+ if (identity) {
+ edgeIdStats.add(ValueCodec.requireIdentifierType(id, "edge identifier", null));
+ edgeIds.add(id);
+ if (edgeIdIndex) edgeIndexKeys.add(IdentifierIndex.keyOf(id));
+ final int labelCode = edgeLabels.codeOf(label);
+ edgeLabelCodes.add(labelCode);
+ edgeLabelHistogram.increment(labelCode);
+ }
+ if (full) {
+ currentEdge = ordinal;
+ props.forEach(edgePropertyVisitor);
+ }
+ edgeCount++;
+ }
+
+ private int resolve(final Object vertexId, final String side, final int edgeOrdinal) {
+ final int idx = vertexOrdinals.indexOf(vertexId);
+ if (!vertexOrdinals.indexExists(idx)) {
+ throw new IllegalStateException("Edge " + edgeOrdinal + " has " + side + "-vertex identifier "
+ + vertexId + " that was not emitted by the vertex scan");
+ }
+ return vertexOrdinals.indexGet(idx);
+ }
+
+ // ------------------------------------------------------------------ variable scan
+
+ private final PropertyVisitor variableVisitor = (key, value) -> {
+ final int known = variableKeys.size();
+ if (variableKeys.codeOf(key) < known) {
+ throw new UnsupportedSnapshotDataException("Duplicate variable key '" + key + "'");
+ }
+ if (value == null) variableStats.addNull();
+ else variableStats.add(ValueCodec.requireType(value, "variable", key));
+ variableValues.add(value);
+ };
+
+ // ------------------------------------------------------------------ adjacency
+
+ private void buildAdjacency() {
+ final int[] out = edgeOut.buffer;
+ final int[] in = edgeIn.buffer;
+ outOffsets = offsets(out);
+ inOffsets = offsets(in);
+ outNeighbors = new int[edgeCount];
+ inNeighbors = new int[edgeCount];
+ // with implicit out-edges the out-adjacency position of an edge is its ordinal
+ if (identity && !grouped) outEdges = new int[edgeCount];
+ if (identity) inEdges = new int[edgeCount];
+ final int[] outCursor = cursors(outOffsets);
+ final int[] inCursor = cursors(inOffsets);
+ for (int e = 0; e < edgeCount; e++) {
+ final int p = outCursor[out[e]]++;
+ outNeighbors[p] = in[e];
+ if (outEdges != null) outEdges[p] = e;
+ final int q = inCursor[in[e]]++;
+ inNeighbors[q] = out[e];
+ if (inEdges != null) inEdges[q] = e;
+ }
+ }
+
+ private long[] offsets(final int[] endpoints) {
+ final long[] offsets = new long[vertexCount + 1];
+ for (int e = 0; e < edgeCount; e++) offsets[endpoints[e] + 1]++;
+ for (int v = 0; v < vertexCount; v++) offsets[v + 1] += offsets[v];
+ return offsets;
+ }
+
+ private int[] cursors(final long[] offsets) {
+ final int[] cursors = new int[vertexCount];
+ for (int v = 0; v < vertexCount; v++) cursors[v] = (int) offsets[v];
+ return cursors;
+ }
+
+ // ------------------------------------------------------------------ writing
+
+ private void writeVertices() {
+ final Manifest.ColumnInfo idInfo = writeIds(SegmentPaths.VERTEX_IDS_DIR, vertexIds, vertexIdStats,
+ vertexCount);
+ manifest.setVertexIds(idInfo);
+ manifest.setVertexIdIndexExact(IdentifierIndex.exactType(idInfo) != null);
+ if (multiLabels) {
+ writeLabelLists();
+ manifest.setVertexLabelLayout(Manifest.VertexLabelLayout.MULTI);
+ } else {
+ writeLabels(SegmentPaths.VERTEX_LABELS, vertexLabels, vertexLabelCodes, vertexCount);
+ manifest.setVertexLabelLayout(Manifest.VertexLabelLayout.SINGLE);
+ }
+ manifest.setVertexLabels(new ArrayList<>(vertexLabels.labels()));
+ manifest.setVertexLabelCounts(vertexLabelHistogram.toArray(vertexLabels.size()));
+ writeIndex("vertex", SegmentPaths.VERTEX_IDS_DIR, SegmentPaths.VERTEX_ID_INDEX_KEYS,
+ SegmentPaths.VERTEX_ID_INDEX_ORDINALS, idInfo, vertexIndexKeys, vertexCount);
+ vertexIds.clear();
+ }
+
+ private void writeEdges() {
+ final Manifest.ColumnInfo idInfo = writeIds(SegmentPaths.EDGE_IDS_DIR, edgeIds, edgeIdStats, edgeCount);
+ manifest.setEdgeIds(idInfo);
+ manifest.setEdgeIdIndexExact(IdentifierIndex.exactType(idInfo) != null);
+ writeLabels(SegmentPaths.EDGE_LABELS, edgeLabels, edgeLabelCodes, edgeCount);
+ manifest.setEdgeLabels(new ArrayList<>(edgeLabels.labels()));
+ manifest.setEdgeLabelCounts(edgeLabelHistogram.toArray(edgeLabels.size()));
+ writeInts(SegmentPaths.EDGE_OUT_VERTICES, edgeOut.buffer, edgeCount);
+ writeInts(SegmentPaths.EDGE_IN_VERTICES, edgeIn.buffer, edgeCount);
+ if (edgeIdIndex) {
+ writeIndex("edge", SegmentPaths.EDGE_IDS_DIR, SegmentPaths.EDGE_ID_INDEX_KEYS,
+ SegmentPaths.EDGE_ID_INDEX_ORDINALS, idInfo, edgeIndexKeys, edgeCount);
+ }
+ edgeIds.clear();
+ }
+
+ private void writeAdjacency() {
+ writeLongs(SegmentPaths.OUT_OFFSETS, outOffsets, vertexCount + 1);
+ writeInts(SegmentPaths.OUT_NEIGHBORS, outNeighbors, edgeCount);
+ writeLongs(SegmentPaths.IN_OFFSETS, inOffsets, vertexCount + 1);
+ writeInts(SegmentPaths.IN_NEIGHBORS, inNeighbors, edgeCount);
+ if (identity) {
+ if (outEdges != null) writeInts(SegmentPaths.OUT_EDGES, outEdges, edgeCount);
+ writeInts(SegmentPaths.IN_EDGES, inEdges, edgeCount);
+ }
+ }
+
+ private void writeProperties() {
+ final List<Manifest.ColumnInfo> valueInfos = new ArrayList<>();
+ final List<Manifest.ColumnInfo> idInfos = new ArrayList<>();
+ final List<Manifest.VertexKeyInfo> keyInfos = new ArrayList<>();
+ for (int code = 0; code < vertexProperties.size(); code++) {
+ final PropertyEntries entries = vertexProperties.get(code);
+ final int count = entries.ordinals.size();
+ final boolean multi = entries.multi;
+ final long elementCount = multi ? count : vertexCount;
+ final Manifest.ColumnInfo valueInfo = entries.stats.toInfo(elementCount);
+ final Manifest.ColumnInfo idInfo = entries.idStats.toInfo(elementCount);
+ try (VertexPropertyColumnWriter writer = VertexPropertyColumnWriter.create(dir::segmentPath, code,
+ elementCount, valueInfo, idInfo)) {
+ for (int i = 0; i < count; i++) {
+ writer.append(multi ? i : entries.ordinals.get(i), entries.ids.get(i), entries.values.get(i));
+ }
+ segments.addAll(writer.finish());
+ }
+ Manifest.OwnerEncoding ownerEncoding = null;
+ if (multi) {
+ ownerEncoding = OwnerWriter.encodingOf(vertexCount, entries.owners);
+ try (OwnerWriter writer = OwnerWriter.create(dir::segmentPath, code, vertexCount,
+ entries.owners)) {
+ int start = 0;
+ while (start < count) {
+ int end = start + 1;
+ while (end < count && entries.ordinals.get(end) == entries.ordinals.get(start)) end++;
+ writer.owner(entries.ordinals.get(start), end - start);
+ start = end;
+ }
+ segments.addAll(writer.finish(count));
+ }
+ }
+ // the vertex-property ordinal is the entry index, except that a dense single column is indexed by
+ // the vertex ordinal
+ final boolean byVertex = !multi && valueInfo.encoding().name().startsWith("DENSE");
+ final Map<Integer, Manifest.ColumnInfo> metaInfos = new TreeMap<>();
+ for (final Map.Entry<Integer, MetaEntries> meta : entries.meta.entrySet()) {
+ final MetaEntries me = meta.getValue();
+ final long metaElements = byVertex ? vertexCount : count;
+ final Manifest.ColumnInfo metaInfo = me.stats.toInfo(metaElements);
+ try (ColumnWriter writer = ColumnWriter.create(dir::segmentPath,
+ SegmentPaths.vertexPropertyMetaDir(code, meta.getKey()), metaElements, metaInfo)) {
+ for (int i = 0; i < me.entryIndexes.size(); i++) {
+ final int entry = me.entryIndexes.get(i);
+ writer.append(byVertex ? entries.ordinals.get(entry) : entry, me.values.get(i));
+ }
+ segments.addAll(writer.finish());
+ }
+ metaInfos.put(meta.getKey(), metaInfo);
+ }
+ keyInfos.add(new Manifest.VertexKeyInfo(multi ? Manifest.PropertyLayout.MULTI
+ : Manifest.PropertyLayout.SINGLE, ownerEncoding, count, metaInfos));
+ valueInfos.add(valueInfo);
+ idInfos.add(idInfo);
+ vertexProperties.set(code, null);
+ }
+ manifest.setVertexKeys(new ArrayList<>(vertexKeys.labels()));
+ manifest.setVertexProperties(valueInfos);
+ manifest.setVertexPropertyIds(idInfos);
+ manifest.setVertexKeyInfos(keyInfos);
+ manifest.setMetaKeys(new ArrayList<>(metaKeys.labels()));
+
+ final List<Manifest.ColumnInfo> edgeInfos = new ArrayList<>();
+ for (int code = 0; code < edgeProperties.size(); code++) {
+ final PropertyEntries entries = edgeProperties.get(code);
+ final Manifest.ColumnInfo info = entries.stats.toInfo(edgeCount);
+ try (ColumnWriter writer = ColumnWriter.create(dir::segmentPath, SegmentPaths.edgePropertyDir(code),
+ edgeCount, info)) {
+ for (int i = 0; i < entries.ordinals.size(); i++) {
+ writer.append(entries.ordinals.get(i), entries.values.get(i));
+ }
+ segments.addAll(writer.finish());
+ }
+ edgeInfos.add(info);
+ edgeProperties.set(code, null);
+ }
+ manifest.setEdgeKeys(new ArrayList<>(edgeKeys.labels()));
+ manifest.setEdgeProperties(edgeInfos);
+
+ final int variableCount = variableKeys.size();
+ if (variableCount > 0) {
+ final Manifest.ColumnInfo info = variableStats.toInfo(variableCount);
+ try (ColumnWriter writer = ColumnWriter.create(dir::segmentPath, SegmentPaths.VARIABLES_DIR,
+ variableCount, info)) {
+ for (int code = 0; code < variableCount; code++) writer.append(code, variableValues.get(code));
+ segments.addAll(writer.finish());
+ }
+ manifest.setVariableKeys(new ArrayList<>(variableKeys.labels()));
+ manifest.setVariables(info);
+ }
+ }
+
+ private Manifest.ColumnInfo writeIds(final String relativeDir, final List<Object> ids,
+ final ColumnStats stats, final int count) {
+ final Manifest.ColumnInfo info = stats.toInfo(count);
+ try (ColumnWriter writer = ColumnWriter.create(dir::segmentPath, relativeDir, count, info)) {
+ for (int i = 0; i < count; i++) writer.append(i, ids.get(i));
+ segments.addAll(writer.finish());
+ }
+ return info;
+ }
+
+ private void writeLabels(final String relativePath, final LabelDictionary dictionary, final IntArrayList codes,
+ final int count) {
+ try (SegmentWriter writer = LabelDictionary.createCodeWriter(dir.segmentPath(relativePath),
+ dictionary.size())) {
+ for (int i = 0; i < count; i++) LabelDictionary.writeCode(writer, codes.buffer[i]);
+ writer.finish();
+ segments.add(writer.info(relativePath));
+ }
+ }
+
+ private void writeLabelLists() {
+ try (LabelListWriter writer = LabelListWriter.create(dir::segmentPath, vertexLabels.size())) {
+ int position = 0;
+ for (int v = 0; v < vertexCount; v++) {
+ for (int n = vertexLabelCounts.get(v); n > 0; n--) writer.add(vertexLabelCodes.get(position++));
+ writer.endVertex();
+ }
+ segments.addAll(writer.finish(vertexCount));
+ }
+ }
+
+ // the identifier column must be complete, and is mapped when the index is not exact
+ private void writeIndex(final String kind, final String idsDir, final String keysPath,
+ final String ordinalsPath, final Manifest.ColumnInfo idInfo, final LongArrayList keys,
+ final int count) {
+ final ValueType exactType = IdentifierIndex.exactType(idInfo);
+ final ColumnReader ids = exactType == null
+ ? ColumnReader.open(ColumnReader.readOnlyOpener(dir.path()), idsDir, count, idInfo) : null;
+ try (IdentifierIndexWriter writer = IdentifierIndexWriter.create(dir.segmentPath(keysPath),
+ dir.segmentPath(ordinalsPath), kind, count, exactType, ids)) {
+ writer.addAllSorting(keys.buffer, count);
+ segments.addAll(writer.finish(keysPath, ordinalsPath));
+ } finally {
+ if (ids != null) ids.close();
+ }
+ }
+
+ private void writeInts(final String relativePath, final int[] values, final int count) {
+ try (SegmentWriter writer = SegmentWriter.create(dir.segmentPath(relativePath), Integer.BYTES)) {
+ for (int i = 0; i < count; i++) writer.writeInt(values[i]);
+ writer.finish();
+ segments.add(writer.info(relativePath));
+ }
+ }
+
+ private void writeLongs(final String relativePath, final long[] values, final int count) {
+ try (SegmentWriter writer = SegmentWriter.create(dir.segmentPath(relativePath), Long.BYTES)) {
+ for (int i = 0; i < count; i++) writer.writeLong(values[i]);
+ writer.finish();
+ segments.add(writer.info(relativePath));
+ }
+ }
+ }
+
+ /**
+ * The entries of one property key in ascending ordinal order, with the statistics of their value types and, for
+ * vertex properties, of their identifier types. A vertex can have several vertex properties of the key, which
+ * are consecutive entries with the same ordinal. The meta-property entries refer to their vertex property by entry
+ * index.
+ */
+ private static final class PropertyEntries {
+ private final ColumnStats stats = new ColumnStats();
+ private final ColumnStats idStats;
+ private final IntArrayList ordinals = new IntArrayList();
+ private final List<Object> values = new ArrayList<>();
+ private final List<Object> ids;
+ private final Map<Integer, MetaEntries> meta = new TreeMap<>();
+ private boolean multi;
+ private int owners;
+
+ PropertyEntries(final boolean withIds) {
+ this.idStats = withIds ? new ColumnStats() : null;
+ this.ids = withIds ? new ArrayList<>() : null;
+ }
+
+ void add(final int ordinal, final Object id, final Object value, final String kind, final String key) {
+ final int n = ordinals.size();
+ if (n > 0 && ordinals.get(n - 1) == ordinal) {
+ if (ids == null) {
+ throw new UnsupportedSnapshotDataException("Duplicate " + kind + " '" + key + "' on ordinal "
+ + ordinal);
+ }
+ multi = true;
+ } else {
+ owners++;
+ }
+ if (ids != null) {
+ idStats.add(ValueCodec.requireIdentifierType(id, kind + " identifier", key));
+ ids.add(id);
+ }
+ if (value == null) stats.addNull();
+ else stats.add(ValueCodec.requireType(value, kind, key), value);
+ ordinals.add(ordinal);
+ values.add(value);
+ }
+
+ MetaEntries meta(final int metaKeyCode) {
+ return meta.computeIfAbsent(metaKeyCode, c -> new MetaEntries());
+ }
+ }
+
+ /**
+ * The entries of one meta-property key of one vertex-property key in ascending entry order.
+ */
+ private static final class MetaEntries {
+ private final ColumnStats stats = new ColumnStats();
+ private final IntArrayList entryIndexes = new IntArrayList();
+ private final List<Object> values = new ArrayList<>();
+
+ void add(final int entryIndex, final Object value, final String key) {
+ final int n = entryIndexes.size();
+ if (n > 0 && entryIndexes.get(n - 1) == entryIndex) {
+ throw new UnsupportedSnapshotDataException("Duplicate meta-property '" + key + "'");
+ }
+ if (value == null) stats.addNull();
+ else stats.add(ValueCodec.requireType(value, "meta-property", key), value);
+ entryIndexes.add(entryIndex);
+ values.add(value);
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/PropertySpool.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/PropertySpool.java
new file mode 100644
index 0000000000..890d2ae8d4
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/PropertySpool.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.build;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentHeader;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.zip.CRC32C;
+
+/**
+ * An append-only scratch spool of variable-length records, stored as a width-1 segment with a regular
+ * {@link SegmentHeader} so that {@link PropertySpoolReader} can read it back through a
+ * {@link org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentReader}. The header is reserved as zero bytes
+ * when the first bytes are flushed and filled in, with the byte count and the checksum, by {@link #finish()}.
+ * <p/>
+ * Unlike a {@link org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentWriter}, a spool costs only a small heap
+ * buffer when it is idle, because by default it holds no open file: the file is opened in append mode for each flush.
+ * That lets a build keep one spool per property key open without exhausting file handles or the memory budget. A spool
+ * that is written continuously can request a persistent channel instead.
+ * <p/>
+ * The spool is not thread-safe. I/O failures are reported as {@link UncheckedIOException}.
+ */
+final class PropertySpool implements AutoCloseable {
+
+ private final Path path;
+ private final byte[] buffer;
+ private final boolean keepOpen;
+ private final CRC32C crc = new CRC32C();
+ private FileChannel channel;
+ private int position;
+ private long payloadBytes;
+ private long records;
+ private boolean created;
+ private boolean finished;
+
+ /**
+ * @param path the spool file, whose parent directory must exist
+ * @param bufferBytes the size of the heap buffer; a record larger than the buffer is written straight through
+ * @param keepOpen whether to keep the file open until {@link #finish()} instead of for each flush only
+ */
+ PropertySpool(final Path path, final int bufferBytes, final boolean keepOpen) {
+ this.path = path;
+ this.buffer = new byte[bufferBytes];
+ this.keepOpen = keepOpen;
+ }
+
+ Path path() {
+ return path;
+ }
+
+ /**
+ * The number of records appended.
+ */
+ long records() {
+ return records;
+ }
+
+ void write(final SpoolRecord record) {
+ if (finished) throw new IllegalStateException("Spool " + path + " is finished");
+ final byte[] src = record.bytes();
+ final int length = record.length();
+ records++;
+ if (length > buffer.length - position) {
+ flush();
+ if (length >= buffer.length) {
+ append(src, 0, length);
+ return;
+ }
+ }
+ System.arraycopy(src, 0, buffer, position, length);
+ position += length;
+ }
+
+ private void flush() {
+ if (position == 0) return;
+ append(buffer, 0, position);
+ position = 0;
+ }
+
+ private void append(final byte[] src, final int offset, final int length) {
+ try {
+ final boolean temporary = channel == null;
+ final FileChannel ch = temporary ? openChannel() : channel;
+ try {
+ final ByteBuffer bb = ByteBuffer.wrap(src, offset, length);
+ while (bb.hasRemaining()) ch.write(bb);
+ } finally {
+ if (temporary && !keepOpen) ch.close();
+ }
+ if (temporary && keepOpen) channel = ch;
+ crc.update(src, offset, length);
+ payloadBytes += length;
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private FileChannel openChannel() throws IOException {
+ final FileChannel ch = FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
+ StandardOpenOption.APPEND);
+ if (!created) {
+ // reserve the header; it stays all zero, and so invalid, until finish()
+ final ByteBuffer header = ByteBuffer.allocate(SegmentHeader.SIZE);
+ while (header.hasRemaining()) ch.write(header);
+ created = true;
+ }
+ return ch;
+ }
+
+ /**
+ * Flushes the spool and writes its header. Idempotent.
+ */
+ void finish() {
+ if (finished) return;
+ flush();
+ try {
+ if (!created) {
+ // nothing was written; still produce a valid, empty segment
+ openChannel().close();
+ }
+ if (channel != null) {
+ channel.close();
+ channel = null;
+ }
+ try (FileChannel ch = FileChannel.open(path, StandardOpenOption.WRITE)) {
+ final ByteBuffer header = new SegmentHeader(1, payloadBytes, crc.getValue()).toBuffer();
+ while (header.hasRemaining()) ch.write(header, header.position());
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ finished = true;
+ }
+
+ /**
+ * Finishes the spool if it is not finished.
+ */
+ @Override
+ public void close() {
+ finish();
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/PropertySpoolReader.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/PropertySpoolReader.java
new file mode 100644
index 0000000000..c8f536c914
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/PropertySpoolReader.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.build;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentReader;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ValueCodec;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ValueType;
+
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.VarHandle;
+import java.nio.ByteOrder;
+import java.nio.file.Path;
+
+/**
+ * Reads back the records of a finished {@link PropertySpool} sequentially. The caller knows the record layout, which is
+ * described by {@link SpoolRecord}.
+ */
+final class PropertySpoolReader implements AutoCloseable {
+
+ private static final VarHandle INT = MethodHandles.byteArrayViewVarHandle(int[].class, ByteOrder.LITTLE_ENDIAN);
+ private static final VarHandle LONG = MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN);
+
+ /**
+ * A reusable holder for the payload of one encoded value.
+ */
+ static final class Payload {
+ byte[] bytes = new byte[64];
+ int length;
+ }
+
+ private final SegmentReader reader;
+ private final byte[] scratch = new byte[8];
+
+ PropertySpoolReader(final Path path, final int bufferBytes) {
+ this.reader = SegmentReader.open(path, bufferBytes);
+ }
+
+ boolean hasRemaining() {
+ return reader.hasRemaining();
+ }
+
+ int readInt() {
+ reader.readBytes(scratch, 0, 4);
+ return (int) INT.get(scratch, 0);
+ }
+
+ long readLong() {
+ reader.readBytes(scratch, 0, 8);
+ return (long) LONG.get(scratch, 0);
+ }
+
+ /**
+ * Reads the type code of an encoded value. A null value reads as {@link ValueType#NULL} and has no payload, so the
+ * caller must not call {@link #readPayload} for it.
+ */
+ ValueType readType() {
+ final ValueType type = ValueCodec.typeOfCode(reader.readByte());
+ if (type == null) throw new IllegalStateException("Corrupt spool " + reader.path() + ": invalid type code");
+ return type;
+ }
+
+ /**
+ * Reads the length, when the type is variable-width, and the payload of a value whose type was just read.
+ */
+ void readPayload(final ValueType type, final Payload into) {
+ final int length = type.isFixedWidth() ? type.width() : readInt();
+ if (length < 0) throw new IllegalStateException("Corrupt spool " + reader.path() + ": negative length");
+ if (into.bytes.length < length) into.bytes = new byte[Math.max(length, into.bytes.length * 2)];
+ reader.readBytes(into.bytes, 0, length);
+ into.length = length;
+ }
+
+ @Override
+ public void close() {
+ reader.close();
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/SnapshotBuilder.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/SnapshotBuilder.java
new file mode 100644
index 0000000000..d4a30f7ffc
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/SnapshotBuilder.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.build;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.SnapshotSource;
+
+import java.nio.file.Path;
+
+/**
+ * Builds a snapshot bundle from a {@link SnapshotSource}. The build runs in a sibling directory named
+ * {@code <target>.tmp-<uuid>}, which is moved to the target when complete. The build fails if the target already
+ * exists. I/O failures are reported as {@link java.io.UncheckedIOException} and unsupported data as
+ * {@link org.apache.tinkerpop.gremlin.structure.snapshot.spi.UnsupportedSnapshotDataException}.
+ */
+public interface SnapshotBuilder {
+
+ BuildStats build(SnapshotSource source, Path target, BuildOptions options);
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/SpoolRecord.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/SpoolRecord.java
new file mode 100644
index 0000000000..424cfbe0de
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/SpoolRecord.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.build;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ValueCodec;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ValueType;
+
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.VarHandle;
+import java.nio.ByteOrder;
+import java.util.Arrays;
+
+/**
+ * A reusable buffer in which one spool record is assembled before it is appended to a {@link PropertySpool}. A record
+ * is a sequence of int32 and int64 fields and encoded values. An encoded value is its one-byte {@link ValueType} code,
+ * then an int32 length when the type is variable-width, then the canonical payload of
+ * {@link org.apache.tinkerpop.gremlin.structure.snapshot.format.ValueCodec}. Fixed-width values are encoded without
+ * allocating. A null value is only the code of {@link ValueType#NULL}, without a length or a payload. All numbers are
+ * little-endian.
+ */
+final class SpoolRecord {
+
+ private static final VarHandle INT = MethodHandles.byteArrayViewVarHandle(int[].class, ByteOrder.LITTLE_ENDIAN);
+ private static final VarHandle LONG = MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN);
+
+ private byte[] buffer = new byte[256];
+ private int length;
+
+ void reset() {
+ length = 0;
+ }
+
+ byte[] bytes() {
+ return buffer;
+ }
+
+ int length() {
+ return length;
+ }
+
+ void putInt(final int value) {
+ ensure(4);
+ INT.set(buffer, length, value);
+ length += 4;
+ }
+
+ void putLong(final long value) {
+ ensure(8);
+ LONG.set(buffer, length, value);
+ length += 8;
+ }
+
+ /**
+ * Appends the marker of a null value, which is the code of {@link ValueType#NULL} alone.
+ */
+ void putNull() {
+ ensure(1);
+ buffer[length++] = ValueType.NULL.code();
+ }
+
+ /**
+ * Appends the type code, the length for a variable-width type and the payload of a value.
+ *
+ * @return the offset in {@link #bytes()} at which the payload starts, which runs to {@link #length()}
+ */
+ int putValue(final ValueType type, final Object value) {
+ ensure(1 + 4);
+ buffer[length++] = type.code();
+ if (type.isFixedWidth()) {
+ final int width = type.width();
+ ensure(width);
+ final long bits = ValueCodec.fixedBits(type, value);
+ final int start = length;
+ for (int i = 0; i < width; i++) buffer[start + i] = (byte) (bits >>> (8 * i));
+ length += width;
+ return start;
+ }
+ final byte[] encoded = ValueCodec.encode(type, value);
+ INT.set(buffer, length, encoded.length);
+ length += 4;
+ ensure(encoded.length);
+ System.arraycopy(encoded, 0, buffer, length, encoded.length);
+ final int start = length;
+ length += encoded.length;
+ return start;
+ }
+
+ private void ensure(final int extra) {
+ final long needed = (long) length + extra;
+ if (needed > buffer.length) {
+ if (needed > Integer.MAX_VALUE - 8) throw new IllegalStateException("Spool record too large");
+ buffer = Arrays.copyOf(buffer, (int) Math.max(needed, Math.min((long) buffer.length * 2, Integer.MAX_VALUE - 8)));
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/StreamingSnapshotBuilder.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/StreamingSnapshotBuilder.java
new file mode 100644
index 0000000000..fa3296d6a9
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/build/StreamingSnapshotBuilder.java
@@ -0,0 +1,999 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.build;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.BuildDirectory;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ColumnEncoding;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ColumnReader;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ColumnStats;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ColumnWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.IdentifierIndex;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.IdentifierIndexWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.LabelCounts;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.LabelDictionary;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.LabelListWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.Manifest;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.MappedSegment;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.OwnerWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentHeader;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentPaths;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentReader;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SegmentWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.SnapshotLayout;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ValueCodec;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.ValueType;
+import org.apache.tinkerpop.gremlin.structure.snapshot.format.VertexPropertyColumnWriter;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.EdgeScanOrder;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.PropertySource;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.PropertyVisitor;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.SnapshotSource;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.SourceVersion;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.UnsupportedSnapshotDataException;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.VertexPropertySource;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.VertexPropertyVisitor;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.OptionalLong;
+import java.util.TreeMap;
+import java.util.function.Function;
+
+/**
+ * Builds a snapshot bundle with bounded heap. Provider records are copied straight into scratch spools and
+ * memory-mapped segments, following the streaming construction steps of the spike document:
+ * <ol>
+ * <li>the vertex scan assigns ordinals and spools labels, identifiers, identifier-index keys and property records;</li>
+ * <li>the vertex identifier index is produced by an external sort of the key spool, or by a copy when the keys
+ * arrived sorted;</li>
+ * <li>the edge scan resolves endpoints through that index, spools the same things for edges, appends the endpoint
+ * ordinals to the edge endpoint segments and counts degrees in memory-mapped arrays, and on the grouped fast path
+ * also appends the out-neighbors sequentially;</li>
+ * <li>the degree arrays are prefix-summed into the offset segments and converted into fill cursors;</li>
+ * <li>the endpoint segments are replayed to scatter the incoming adjacency, and on the generic path the outgoing
+ * adjacency as well, through the mapped cursors;</li>
+ * <li>labels are narrowed, the spools are compacted into columns, the edge index is produced, and the bundle is
+ * published.</li>
+ * </ol>
+ * The heap holds the label and property-key dictionaries, a small buffer per open spool, and at most one sort chunk,
+ * all sized from {@link BuildOptions#memoryBudgetBytes()}. The output is byte-for-byte that of any other builder that
+ * follows the determinism rules of the spike document, see the notes below.
+ * <p/>
+ * Choices the document leaves open, which other builders must share for identical output:
+ * <ul>
+ * <li>Label codes and property-key codes are assigned in order of first appearance in the scan, in separate
+ * dictionaries for vertex labels, edge labels, vertex keys and edge keys. The keys of one element are assigned in
+ * the order the source visits them.</li>
+ * <li>{@code outEdgesImplicit} is true exactly when the source reports {@code GROUPED_BY_OUT_VERTEX}, whatever the
+ * layout and whatever {@link BuildOptions#groupedFastPath()} says. The option only selects the construction path.
+ * The monotonic out-vertex check applies to every build from a grouped source.</li>
+ * <li>Parallel edges are not merged. The entries of one vertex's adjacency are in ascending edge ordinal.</li>
+ * <li>Only what the layout publishes is read from the source: {@code TOPOLOGY} ignores labels, edge identifiers and
+ * properties, {@code IDENTITY} ignores properties, and ignored data is not validated.</li>
+ * <li>Dictionaries, identifier columns and the per-key column lists of the manifest are empty or null for the
+ * layouts that do not publish them. The exact-index flags are true exactly when the identifier column holds a single
+ * integral type, independent of whether the edge index is published.</li>
+ * <li>{@code edgeIdIndex} is true exactly for a non-{@code TOPOLOGY} layout with {@link BuildOptions#edgeIdIndex()},
+ * and duplicate edge identifiers are detected only when the edge index is built.</li>
+ * <li>The source version is read once, before the vertex scan. The source defaults are read after the scans and
+ * recorded for every layout. Variables are scanned, after the edges, only for the {@code FULL} layout.</li>
+ * <li>Vertex labels: the layout is {@code MULTI} exactly when some vertex has a label count other than one. The
+ * label dictionary is in order of first appearance, per vertex in source order.</li>
+ * <li>A vertex key is {@code MULTI} exactly when some vertex has more than one vertex property for it. Vertex-property
+ * ordinals of such a key are assigned in vertex order and source order within a vertex. A null value is counted as
+ * present. Meta keys are coded in order of first appearance, in vertex, vertex-property and meta order, in one
+ * dictionary for all vertex keys, and a meta column holds entries only for vertex properties that have the meta key;
+ * a repeated meta key on one vertex property is rejected. Variable keys are coded in {@code scanVariables} order and
+ * a repeated variable key is rejected.</li>
+ * </ul>
+ * Count-hint mismatches, order violations of a grouped scan and edges with unknown endpoints fail with
+ * {@link IllegalStateException}. Unsupported data fails with {@link UnsupportedSnapshotDataException}.
+ */
+public final class StreamingSnapshotBuilder implements SnapshotBuilder {
+
+ // vertex and edge ordinals are 32-bit and the largest allowed count is Integer.MAX_VALUE - 1
+ private static final long MAX_ELEMENTS = Integer.MAX_VALUE - 1L;
+
+ private static final String SPOOL_DIR = "spool/";
+ private static final String DEGREE_DIR = "degree/";
+
+ @Override
+ public BuildStats build(final SnapshotSource source, final Path target, final BuildOptions options) {
+ Objects.requireNonNull(source);
+ Objects.requireNonNull(target);
+ Objects.requireNonNull(options);
+ try (BuildDirectory dir = BuildDirectory.create(target, options.scratchDirectory().orElse(null))) {
+ return new Build(source, dir, options).run();
+ }
+ }
+
+ /**
+ * The spool and statistics of one property key.
+ */
+ private static final class KeyColumn {
+ final PropertySpool spool;
+ final ColumnStats values = new ColumnStats();
+ // vertex-property identifiers; unused for edges
+ final ColumnStats ids = new ColumnStats();
+ int lastOrdinal = -1;
+ // vertex properties only: the number of vertex properties, of vertices owning at least one, and whether some
+ // vertex owns more than one
+ long propertyCount;
+ long ownerCount;
+ boolean multi;
+ // vertex properties only: the meta columns by meta key code
+ final Map<Integer, MetaColumn> metas = new TreeMap<>();
+
+ KeyColumn(final PropertySpool spool) {
+ this.spool = spool;
+ }
+ }
+
+ /**
+ * The spool and statistics of one meta key of one vertex key. A record is the running index of the vertex property
+ * among those of its key (int64), the owning vertex ordinal (int32) and the encoded value.
+ */
+ private static final class MetaColumn {
+ final PropertySpool spool;
+ final ColumnStats values = new ColumnStats();
+ long lastIndex = -1;
+
+ MetaColumn(final PropertySpool spool) {
+ this.spool = spool;
+ }
+ }
+
+ /**
+ * The state of one build.
+ */
+ private static final class Build {
+ private final SnapshotSource source;
+ private final BuildDirectory dir;
+ private final SnapshotLayout layout;
+
+ // what the layout publishes
+ private final boolean withIdentity;
+ private final boolean withProperties;
+ private final boolean edgeIndex;
+ private final boolean grouped;
+ private final boolean fastPath;
+
+ private final long budget;
+ private final int ioBuffer;
+ private final int spoolBuffer;
+ private final ExternalIndexSorter sorter;
+
+ private final List<BuildStats.Phase> phases = new ArrayList<>();
+ private final List<Manifest.SegmentInfo> segments = new ArrayList<>();
+ private final List<AutoCloseable> open = new ArrayList<>();
+ private final SpoolRecord record = new SpoolRecord();
+
+ // vertex scan
+ private int vertexCount;
+ private final LabelDictionary vertexLabels = new LabelDictionary();
+ private final LabelCounts vertexLabelHistogram = new LabelCounts();
+ private final LabelCounts edgeLabelHistogram = new LabelCounts();
+ private final LabelDictionary vertexKeys = new LabelDictionary();
+ private final List<KeyColumn> vertexColumns = new ArrayList<>();
+ private SegmentWriter vertexLabelSpool;
+ private SegmentWriter vertexLabelCountSpool;
+ private boolean multiLabels;
+ private final LabelDictionary metaKeys = new LabelDictionary();
+ private PropertySpool vertexIdSpool;
+ private final ColumnStats vertexIdStats = new ColumnStats();
+ private SegmentWriter vertexKeySpool;
+ private boolean vertexKeysSorted = true;
+ private long lastVertexKey;
+
+ // vertex index
+ private Manifest.ColumnInfo vertexIdInfo;
+ private IdentifierIndex vertexIndex;
+
+ // edge scan
+ private int edgeCount;
+ private final LabelDictionary edgeLabels = new LabelDictionary();
+ private final LabelDictionary edgeKeys = new LabelDictionary();
+ private final List<KeyColumn> edgeColumns = new ArrayList<>();
+ private SegmentWriter edgeLabelSpool;
+ private PropertySpool edgeIdSpool;
+ private final ColumnStats edgeIdStats = new ColumnStats();
+ private SegmentWriter edgeKeySpool;
+ private boolean edgeKeysSorted = true;
+ private long lastEdgeKey;
+ private SegmentWriter outVertices;
+ private SegmentWriter inVertices;
+ private SegmentWriter fastOutNeighbors;
+ private MappedSegment outCursors;
+ private MappedSegment inCursors;
+ private boolean haveLastOut;
+ private int lastOutOrdinal;
+ private ValueType lastOutType;
+ private long lastOutBits;
+ private Object lastOutId;
+ private int lastGroupOrdinal = -1;
+ private Manifest.ColumnInfo edgeIdInfo;
+
+ // variables are few and held in memory
+ private final LabelDictionary variableKeys = new LabelDictionary();
+ private final List<Object> variableValues = new ArrayList<>();
+ private final ColumnStats variableStats = new ColumnStats();
+ private Manifest.ColumnInfo variablesInfo;
+
+ private final VertexProperties vertexProperties = new VertexProperties();
+ private final EdgeProperties edgeProperties = new EdgeProperties();
+
+ Build(final SnapshotSource source, final BuildDirectory dir, final BuildOptions options) {
+ this.source = source;
+ this.dir = dir;
+ this.layout = options.layout();
+ this.withIdentity = layout != SnapshotLayout.TOPOLOGY;
+ this.withProperties = layout == SnapshotLayout.FULL;
+ this.edgeIndex = withIdentity && options.edgeIdIndex();
+ this.grouped = source.edgeScanOrder() == EdgeScanOrder.GROUPED_BY_OUT_VERTEX;
+ this.fastPath = grouped && options.groupedFastPath();
+ this.budget = options.memoryBudgetBytes();
+ this.ioBuffer = (int) Math.max(64, Math.min(SegmentWriter.DEFAULT_BUFFER_BYTES, budget / 64));
+ this.spoolBuffer = (int) Math.max(256, Math.min(32 * 1024, budget / 256));
+ this.sorter = new ExternalIndexSorter(dir::scratchPath, budget);
+ }
+
+ BuildStats run() {
+ try {
+ final SourceVersion version = source.version();
+
+ long start = System.nanoTime();
+ scanVertices();
+ phase("vertex-scan", start);
+
+ start = System.nanoTime();
+ buildVertexIndex();
+ phase("vertex-index", start);
+
+ start = System.nanoTime();
+ scanEdges();
+ phase("edge-scan", start);
+
+ start = System.nanoTime();
+ computeOffsets();
+ phase("offsets", start);
+
+ start = System.nanoTime();
+ replay();
+ phase("replay", start);
+
+ start = System.nanoTime();
+ compact();
+ phase("compaction", start);
+
+ start = System.nanoTime();
+ final Manifest manifest = new Manifest();
+ fillManifest(manifest, version);
+ closeAll();
+ dir.deleteScratch();
+ final long diskBytes = dir.bytesOnDisk();
+ dir.publish(manifest);
+ phases.add(new BuildStats.Phase("publish", System.nanoTime() - start, diskBytes));
+
+ return new BuildStats(phases, segmentBytes(manifest));
+ } finally {
+ closeAll();
+ }
+ }
+
+ // ------------------------------------------------------------ infrastructure
+
+ private void phase(final String name, final long startNanos) {
+ final long elapsed = System.nanoTime() - startNanos;
+ phases.add(new BuildStats.Phase(name, elapsed, dir.bytesOnDisk()));
+ }
+
+ private <T extends AutoCloseable> T track(final T resource) {
+ open.add(resource);
+ return resource;
+ }
+
+ private void closeAll() {
+ for (int i = open.size() - 1; i >= 0; i--) {
+ try {
+ open.get(i).close();
+ } catch (Exception ignored) {
+ // the build is failing, or the resource is already finished; the directory is cleaned up anyway
+ }
+ }
+ open.clear();
+ }
+
+ private Function<String, Path> resolver(final boolean published) {
+ return published ? dir::segmentPath : dir::scratchPath;
+ }
+
+ private Function<String, MappedSegment> opener(final boolean published) {
+ final Path root = published ? dir.path() : dir.scratch();
+ return relativePath -> MappedSegment.open(root.resolve(relativePath), MappedSegment.Mode.READ_ONLY);
+ }
+
+ private SegmentWriter writer(final boolean published, final String relativePath, final int width) {
+ return track(SegmentWriter.create(resolver(published).apply(relativePath), width, ioBuffer));
+ }
+
+ private void publish(final SegmentWriter writer, final boolean published, final String relativePath) {
+ writer.finish();
+ if (published) segments.add(writer.info(relativePath));
+ }
+
+ private void publish(final MappedSegment segment, final String relativePath) {
+ segment.finish();
+ segments.add(segment.info(relativePath));
+ }
+
+ private static void checkCount(final String kind, final OptionalLong hint, final long actual) {
+ if (hint.isPresent() && hint.getAsLong() != actual) {
+ throw new IllegalStateException("The source announced " + hint.getAsLong() + " " + kind
+ + " but emitted " + actual);
+ }
+ }
+
+ private static KeyColumn column(final List<KeyColumn> columns, final int code, final Path path,
+ final int bufferBytes) {
+ if (code < columns.size()) return columns.get(code);
+ if (code != columns.size()) throw new IllegalStateException("Property-key code " + code + " skipped");
+ final KeyColumn column = new KeyColumn(new PropertySpool(path, bufferBytes, false));
+ columns.add(column);
+ return column;
+ }
+
+ // ------------------------------------------------------------ step 1: vertex scan
+
+ private void scanVertices() {
+ if (withIdentity) {
+ vertexLabelSpool = writer(false, SPOOL_DIR + "vertex-labels.bin", 4);
+ vertexLabelCountSpool = writer(false, SPOOL_DIR + "vertex-label-counts.bin", 4);
+ }
+ vertexIdSpool = track(new PropertySpool(dir.scratchPath(SPOOL_DIR + "vertex-ids.bin"), ioBuffer, true));
+ vertexKeySpool = writer(false, SPOOL_DIR + "vertex-index-keys.bin", 8);
+ source.scanVertices(this::onVertex);
+ checkCount("vertices", source.vertexCount(), vertexCount);
+ if (withIdentity) {
+ vertexLabelSpool.finish();
+ vertexLabelCountSpool.finish();
+ }
+ vertexIdSpool.finish();
+ vertexKeySpool.finish();
+ for (final KeyColumn column : vertexColumns) {
+ column.spool.finish();
+ for (final MetaColumn meta : column.metas.values()) meta.spool.finish();
+ }
+ }
+
+ private void onVertex(final Object id, final List<String> labels, final VertexPropertySource properties) {
+ if (vertexCount >= MAX_ELEMENTS) {
+ throw new UnsupportedSnapshotDataException("More than " + MAX_ELEMENTS + " vertices are not supported");
+ }
+ final int ordinal = vertexCount;
+ if (withIdentity) {
+ for (int i = 0; i < labels.size(); i++) {
+ final int code = vertexLabels.codeOf(labels.get(i));
+ vertexLabelSpool.writeInt(code);
+ vertexLabelHistogram.increment(code);
+ }
+ vertexLabelCountSpool.writeInt(labels.size());
+ if (labels.size() != 1) multiLabels = true;
+ }
+
+ final ValueType type = ValueCodec.requireIdentifierType(id, "vertex identifier", null);
+ record.reset();
+ final int payload = record.putValue(type, id);
+ final long key = IdentifierIndex.keyOf(type, record.bytes(), payload, record.length() - payload);
+ vertexIdSpool.write(record);
+ vertexIdStats.add(type);
+ vertexKeySpool.writeLong(key);
+ if (ordinal > 0 && key < lastVertexKey) vertexKeysSorted = false;
+ lastVertexKey = key;
+
+ if (withProperties) {
+ vertexProperties.ordinal = ordinal;
+ properties.forEach(vertexProperties);
+ }
+ vertexCount++;
+ }
+
+ private final class VertexProperties implements VertexPropertyVisitor {
+ int ordinal;
+ private final MetaProperties metaProperties = new MetaProperties();
+
+ @Override
+ public void vertexProperty(final Object id, final String key, final Object value,
+ final PropertySource metas) {
+ final int code = vertexKeys.codeOf(key);
+ final ValueType idType = ValueCodec.requireIdentifierType(id, "vertex property identifier", key);
+ final ValueType type = value == null ? null : ValueCodec.requireType(value, "vertex property", key);
+ final KeyColumn column = column(vertexColumns, code,
+ dir.scratchPath(SPOOL_DIR + "vertex-property-" + code + ".bin"), spoolBuffer);
+ if (column.lastOrdinal == ordinal) {
+ column.multi = true;
+ } else {
+ column.lastOrdinal = ordinal;
+ column.ownerCount++;
+ }
+ final long index = column.propertyCount++;
+ record.reset();
+ record.putInt(ordinal);
+ if (type == null) {
+ record.putNull();
+ } else {
+ record.putValue(type, value);
+ }
+ record.putValue(idType, id);
+ column.spool.write(record);
+ if (type == null) {
+ column.values.addNull();
+ } else {
+ column.values.add(type, value);
+ }
+ column.ids.add(idType);
+
+ metaProperties.column = column;
+ metaProperties.code = code;
+ metaProperties.key = key;
+ metaProperties.ordinal = ordinal;
+ metaProperties.index = index;
+ metas.forEach(metaProperties);
+ }
+ }
+
+ private final class MetaProperties implements PropertyVisitor {
+ KeyColumn column;
+ int code;
+ String key;
+ int ordinal;
+ long index;
+
+ @Override
+ public void property(final String metaKey, final Object value) {
+ final int metaCode = metaKeys.codeOf(metaKey);
+ final ValueType type = value == null ? null
+ : ValueCodec.requireType(value, "meta-property of '" + key + "'", metaKey);
+ MetaColumn meta = column.metas.get(metaCode);
+ if (meta == null) {
+ meta = new MetaColumn(new PropertySpool(dir.scratchPath(SPOOL_DIR + "vertex-meta-" + code + "-"
+ + metaCode + ".bin"), spoolBuffer, false));
+ column.metas.put(metaCode, meta);
+ }
+ if (meta.lastIndex == index) {
+ throw new UnsupportedSnapshotDataException("A vertex property '" + key + "' of vertex " + ordinal
+ + " has more than one value for meta key '" + metaKey + "'");
+ }
+ meta.lastIndex = index;
+ record.reset();
+ record.putLong(index);
+ record.putInt(ordinal);
+ if (type == null) {
+ record.putNull();
+ meta.values.addNull();
+ } else {
+ record.putValue(type, value);
+ meta.values.add(type, value);
+ }
+ meta.spool.write(record);
+ }
+ }
+
+ // ------------------------------------------------------------ step 2: vertex index
+
+ private void buildVertexIndex() {
+ vertexIdInfo = compactIds(SegmentPaths.VERTEX_IDS_DIR, vertexIdSpool, vertexCount, vertexIdStats);
+ final ValueType exact = IdentifierIndex.exactType(vertexIdInfo);
+ final ColumnReader ids = exact == null ? openIds(SegmentPaths.VERTEX_IDS_DIR, vertexCount, vertexIdInfo) : null;
+ writeIndex("vertex", vertexKeySpool.path(), vertexKeysSorted, vertexCount, vertexIdInfo, ids, withIdentity,
+ SegmentPaths.VERTEX_ID_INDEX_KEYS, SegmentPaths.VERTEX_ID_INDEX_ORDINALS, "vertex");
+ final Function<String, MappedSegment> opener = opener(withIdentity);
+ vertexIndex = IdentifierIndex.of(opener.apply(SegmentPaths.VERTEX_ID_INDEX_KEYS),
+ opener.apply(SegmentPaths.VERTEX_ID_INDEX_ORDINALS), exact, ids);
+ }
+
+ /**
+ * Compacts an identifier spool into the identifier column, published when the layout has identity.
+ */
+ private Manifest.ColumnInfo compactIds(final String columnDir, final PropertySpool spool, final long count,
+ final ColumnStats stats) {
+ final Manifest.ColumnInfo info = stats.toInfo(count);
+ try (ColumnWriter writer = ColumnWriter.create(resolver(withIdentity), columnDir, count, info);
+ PropertySpoolReader in = new PropertySpoolReader(spool.path(), ioBuffer)) {
+ final PropertySpoolReader.Payload payload = new PropertySpoolReader.Payload();
+ for (long ordinal = 0; ordinal < count; ordinal++) {
+ final ValueType type = in.readType();
+ in.readPayload(type, payload);
+ writer.appendEncoded(ordinal, type, payload.bytes, 0, payload.length);
+ }
+ final List<Manifest.SegmentInfo> written = writer.finish();
+ if (withIdentity) segments.addAll(written);
+ }
+ return info;
+ }
+
+ private ColumnReader openIds(final String columnDir, final long count, final Manifest.ColumnInfo info) {
+ return track(ColumnReader.open(opener(withIdentity), columnDir, count, info));
+ }
+
+ /**
+ * Writes an identifier index from the key spool, whose entry positions are the ordinals, either by copying the
+ * spool when it is already in index order or by an external sort. The index is published only when asked.
+ */
+ private void writeIndex(final String kind, final Path keySpool, final boolean sorted, final long count,
+ final Manifest.ColumnInfo idInfo, final ColumnReader ids, final boolean published,
+ final String keysPath, final String ordinalsPath, final String sortName) {
+ final Function<String, Path> resolver = resolver(published);
+ try (IdentifierIndexWriter writer = IdentifierIndexWriter.create(resolver.apply(keysPath),
+ resolver.apply(ordinalsPath), kind, count, IdentifierIndex.exactType(idInfo), ids)) {
+ if (sorted) {
+ try (SegmentReader in = SegmentReader.open(keySpool, ioBuffer)) {
+ for (long ordinal = 0; ordinal < count; ordinal++) writer.add(in.readLong(), (int) ordinal);
+ }
+ } else {
+ sorter.sort(keySpool, sortName, writer::add);
+ }
+ final List<Manifest.SegmentInfo> written = writer.finish(keysPath, ordinalsPath);
+ if (published) segments.addAll(written);
+ }
+ }
+
+ // ------------------------------------------------------------ step 3: edge scan
+
+ private void scanEdges() {
+ if (withIdentity) {
+ edgeLabelSpool = writer(false, SPOOL_DIR + "edge-labels.bin", 4);
+ edgeIdSpool = track(new PropertySpool(dir.scratchPath(SPOOL_DIR + "edge-ids.bin"), ioBuffer, true));
+ }
+ if (edgeIndex) edgeKeySpool = writer(false, SPOOL_DIR + "edge-index-keys.bin", 8);
+ outVertices = writer(withIdentity, SegmentPaths.EDGE_OUT_VERTICES, 4);
+ inVertices = writer(withIdentity, SegmentPaths.EDGE_IN_VERTICES, 4);
+ if (fastPath) fastOutNeighbors = writer(true, SegmentPaths.OUT_NEIGHBORS, 4);
+ outCursors = track(MappedSegment.create(dir.scratchPath(DEGREE_DIR + "out.bin"), 4, vertexCount));
+ inCursors = track(MappedSegment.create(dir.scratchPath(DEGREE_DIR + "in.bin"), 4, vertexCount));
+
+ source.scanEdges(this::onEdge);
+ checkCount("edges", source.edgeCount(), edgeCount);
+ if (withProperties) source.scanVariables(this::onVariable);
+
+ if (withIdentity) {
+ edgeLabelSpool.finish();
+ edgeIdSpool.finish();
+ }
+ if (edgeIndex) edgeKeySpool.finish();
+ for (final KeyColumn column : edgeColumns) column.spool.finish();
+ publish(outVertices, withIdentity, SegmentPaths.EDGE_OUT_VERTICES);
+ publish(inVertices, withIdentity, SegmentPaths.EDGE_IN_VERTICES);
+ if (fastPath) publish(fastOutNeighbors, true, SegmentPaths.OUT_NEIGHBORS);
+ }
+
+ private void onEdge(final Object id, final String label, final Object outId, final Object inId,
+ final PropertySource properties) {
+ if (edgeCount >= MAX_ELEMENTS) {
+ throw new UnsupportedSnapshotDataException("More than " + MAX_ELEMENTS + " edges are not supported");
+ }
+ final int ordinal = edgeCount;
+ if (withIdentity) {
+ final int labelCode = edgeLabels.codeOf(label);
+ edgeLabelSpool.writeInt(labelCode);
+ edgeLabelHistogram.increment(labelCode);
+ final ValueType type = ValueCodec.requireIdentifierType(id, "edge identifier", null);
+ record.reset();
+ final int payload = record.putValue(type, id);
+ edgeIdSpool.write(record);
+ edgeIdStats.add(type);
+ if (edgeIndex) {
+ final long key = IdentifierIndex.keyOf(type, record.bytes(), payload, record.length() - payload);
+ edgeKeySpool.writeLong(key);
+ if (ordinal > 0 && key < lastEdgeKey) edgeKeysSorted = false;
+ lastEdgeKey = key;
+ }
+ }
+
+ final int out = resolveOut(outId, ordinal);
+ if (grouped) {
+ if (out < lastGroupOrdinal) {
+ throw new IllegalStateException("The source reports GROUPED_BY_OUT_VERTEX but edge " + ordinal
+ + " has out-vertex ordinal " + out + " after " + lastGroupOrdinal);
+ }
+ lastGroupOrdinal = out;
+ }
+ final int in = vertexIndex.lookup(inId);
+ if (in < 0) {
+ throw new IllegalStateException("Edge " + ordinal + " refers to unknown in-vertex " + inId);
+ }
+
+ outVertices.writeInt(out);
+ inVertices.writeInt(in);
+ outCursors.putInt(out, outCursors.getInt(out) + 1);
+ inCursors.putInt(in, inCursors.getInt(in) + 1);
+ if (fastPath) fastOutNeighbors.writeInt(in);
+
+ if (withProperties) {
+ edgeProperties.ordinal = ordinal;
+ properties.forEach(edgeProperties);
+ }
+ edgeCount++;
+ }
+
+ private void onVariable(final String key, final Object value) {
+ final int before = variableKeys.size();
+ variableKeys.codeOf(key);
+ if (variableKeys.size() == before) {
+ throw new UnsupportedSnapshotDataException("Variable '" + key + "' is emitted more than once");
+ }
+ final ValueType type = value == null ? null : ValueCodec.requireType(value, "variable", key);
+ variableValues.add(value);
+ if (type == null) {
+ variableStats.addNull();
+ } else {
+ variableStats.add(type);
+ }
+ }
+
+ /**
+ * Resolves the out-vertex, reusing the previous ordinal while the identifier repeats. A fixed-width identifier is
+ * remembered by its bits. A variable-width one is remembered by reference, which is safe for the immutable value
+ * classes the format supports.
+ */
+ private int resolveOut(final Object id, final int edgeOrdinal) {
+ final ValueType type = ValueCodec.typeOf(id);
+ if (haveLastOut && type != null && type == lastOutType) {
+ final boolean same = type.isFixedWidth() ? ValueCodec.fixedBits(type, id) == lastOutBits
+ : id.equals(lastOutId);
+ if (same) return lastOutOrdinal;
+ }
+ final int ordinal = vertexIndex.lookup(id);
+ if (ordinal < 0) {
+ throw new IllegalStateException("Edge " + edgeOrdinal + " refers to unknown out-vertex " + id);
+ }
+ haveLastOut = true;
+ lastOutOrdinal = ordinal;
+ lastOutType = type;
+ if (type.isFixedWidth()) {
+ lastOutBits = ValueCodec.fixedBits(type, id);
+ lastOutId = null;
+ } else {
+ lastOutId = id;
+ }
+ return ordinal;
+ }
+
+ private final class EdgeProperties implements PropertyVisitor {
+ int ordinal;
+
+ @Override
+ public void property(final String key, final Object value) {
+ final int code = edgeKeys.codeOf(key);
+ final ValueType type = value == null ? null : ValueCodec.requireType(value, "edge property", key);
+ final KeyColumn column = column(edgeColumns, code,
+ dir.scratchPath(SPOOL_DIR + "edge-property-" + code + ".bin"), spoolBuffer);
+ if (column.lastOrdinal == ordinal) {
+ throw new UnsupportedSnapshotDataException("Edge " + ordinal + " has more than one value for key '"
+ + key + "'");
+ }
+ column.lastOrdinal = ordinal;
+ record.reset();
+ record.putInt(ordinal);
+ if (type == null) {
+ record.putNull();
+ column.values.addNull();
+ } else {
+ record.putValue(type, value);
+ column.values.add(type, value);
+ }
+ column.spool.write(record);
+ }
+ }
+
+ // ------------------------------------------------------------ step 4: offsets
+
+ /**
+ * Prefix-sums the degree arrays into the offset segments and converts each degree array in place into the fill
+ * cursors, which start at the offset of their vertex.
+ */
+ private void computeOffsets() {
+ final SegmentWriter outOffsets = writer(true, SegmentPaths.OUT_OFFSETS, 8);
+ final SegmentWriter inOffsets = writer(true, SegmentPaths.IN_OFFSETS, 8);
+ long outRunning = 0;
+ long inRunning = 0;
+ for (int v = 0; v < vertexCount; v++) {
+ outOffsets.writeLong(outRunning);
+ inOffsets.writeLong(inRunning);
+ final int outDegree = outCursors.getInt(v);
+ final int inDegree = inCursors.getInt(v);
+ outCursors.putInt(v, (int) outRunning);
+ inCursors.putInt(v, (int) inRunning);
+ outRunning += outDegree;
+ inRunning += inDegree;
+ }
+ if (outRunning != edgeCount || inRunning != edgeCount) {
+ throw new IllegalStateException("Degrees sum to " + outRunning + " and " + inRunning + " for "
+ + edgeCount + " edges");
+ }
+ outOffsets.writeLong(outRunning);
+ inOffsets.writeLong(inRunning);
+ publish(outOffsets, true, SegmentPaths.OUT_OFFSETS);
+ publish(inOffsets, true, SegmentPaths.IN_OFFSETS);
+ }
+
+ // ------------------------------------------------------------ step 5: replay
+
+ private void replay() {
+ final boolean outEdges = withIdentity && !grouped;
+ final MappedSegment inNeighbors = track(MappedSegment.create(dir.segmentPath(SegmentPaths.IN_NEIGHBORS), 4,
+ edgeCount));
+ final MappedSegment inEdges = withIdentity
+ ? track(MappedSegment.create(dir.segmentPath(SegmentPaths.IN_EDGES), 4, edgeCount)) : null;
+ final MappedSegment outNeighbors = fastPath ? null
+ : track(MappedSegment.create(dir.segmentPath(SegmentPaths.OUT_NEIGHBORS), 4, edgeCount));
+ final MappedSegment outEdgeSegment = !fastPath && outEdges
+ ? track(MappedSegment.create(dir.segmentPath(SegmentPaths.OUT_EDGES), 4, edgeCount)) : null;
+
+ try (SegmentReader outReader = SegmentReader.open(resolver(withIdentity).apply(SegmentPaths.EDGE_OUT_VERTICES),
+ ioBuffer);
+ SegmentReader inReader = SegmentReader.open(resolver(withIdentity).apply(SegmentPaths.EDGE_IN_VERTICES),
+ ioBuffer)) {
+ for (int edge = 0; edge < edgeCount; edge++) {
+ final int out = outReader.readInt();
+ final int in = inReader.readInt();
+ final int inPosition = inCursors.getInt(in);
+ inCursors.putInt(in, inPosition + 1);
+ inNeighbors.putInt(inPosition, out);
+ if (inEdges != null) inEdges.putInt(inPosition, edge);
+ if (!fastPath) {
+ final int outPosition = outCursors.getInt(out);
+ outCursors.putInt(out, outPosition + 1);
+ outNeighbors.putInt(outPosition, in);
+ if (outEdgeSegment != null) outEdgeSegment.putInt(outPosition, edge);
+ }
+ }
+ }
+
+ publish(inNeighbors, SegmentPaths.IN_NEIGHBORS);
+ if (inEdges != null) publish(inEdges, SegmentPaths.IN_EDGES);
+ if (outNeighbors != null) publish(outNeighbors, SegmentPaths.OUT_NEIGHBORS);
+ if (outEdgeSegment != null) publish(outEdgeSegment, SegmentPaths.OUT_EDGES);
+ }
+
+ // ------------------------------------------------------------ step 6: compaction
+
+ private final List<Manifest.ColumnInfo> vertexValueInfos = new ArrayList<>();
+ private final List<Manifest.ColumnInfo> vertexIdColumnInfos = new ArrayList<>();
+ private final List<Manifest.ColumnInfo> edgeValueInfos = new ArrayList<>();
+ private final List<Manifest.VertexKeyInfo> vertexKeyInfos = new ArrayList<>();
+
+ private void compact() {
+ if (withIdentity) {
+ compactVertexLabels();
+ edgeIdInfo = compactIds(SegmentPaths.EDGE_IDS_DIR, edgeIdSpool, edgeCount, edgeIdStats);
+ if (edgeIndex) {
+ final ValueType exact = IdentifierIndex.exactType(edgeIdInfo);
+ final ColumnReader ids = exact == null
+ ? openIds(SegmentPaths.EDGE_IDS_DIR, edgeCount, edgeIdInfo) : null;
+ writeIndex("edge", edgeKeySpool.path(), edgeKeysSorted, edgeCount, edgeIdInfo, ids, true,
+ SegmentPaths.EDGE_ID_INDEX_KEYS, SegmentPaths.EDGE_ID_INDEX_ORDINALS, "edge");
+ }
+ segments.add(LabelDictionary.narrow(edgeLabelSpool.path(), dir.segmentPath(SegmentPaths.EDGE_LABELS),
+ SegmentPaths.EDGE_LABELS, edgeLabels.size()));
+ }
+ if (withProperties) {
+ compactVertexProperties();
+ compactEdgeProperties();
+ compactVariables();
+ }
+ }
+
+ /**
+ * Narrows the label code spool when every vertex has one label, and otherwise replays it with the per-vertex
+ * counts into the label lists.
+ */
+ private void compactVertexLabels() {
+ if (!multiLabels) {
+ segments.add(LabelDictionary.narrow(vertexLabelSpool.path(),
+ dir.segmentPath(SegmentPaths.VERTEX_LABELS), SegmentPaths.VERTEX_LABELS, vertexLabels.size()));
+ return;
+ }
+ try (LabelListWriter writer = LabelListWriter.create(dir::segmentPath, vertexLabels.size());
+ SegmentReader codes = SegmentReader.open(vertexLabelSpool.path(), ioBuffer);
+ SegmentReader counts = SegmentReader.open(vertexLabelCountSpool.path(), ioBuffer)) {
+ for (int v = 0; v < vertexCount; v++) {
+ final int count = counts.readInt();
+ for (int i = 0; i < count; i++) writer.add(codes.readInt());
+ writer.endVertex();
+ }
+ segments.addAll(writer.finish(vertexCount));
+ }
+ }
+
+ private void compactVertexProperties() {
+ final PropertySpoolReader.Payload value = new PropertySpoolReader.Payload();
+ final PropertySpoolReader.Payload id = new PropertySpoolReader.Payload();
+ for (int code = 0; code < vertexColumns.size(); code++) {
+ final KeyColumn column = vertexColumns.get(code);
+ final boolean multi = column.multi;
+ // in the multi layout the element space is the key's vertex properties
+ final long elementCount = multi ? column.propertyCount : vertexCount;
+ final Manifest.ColumnInfo valueInfo = column.values.toInfo(elementCount);
+ final Manifest.ColumnInfo idInfo = column.ids.toInfo(elementCount);
+ try (VertexPropertyColumnWriter writer = VertexPropertyColumnWriter.create(dir::segmentPath, code,
+ elementCount, valueInfo, idInfo);
+ OwnerWriter owners = multi ? OwnerWriter.create(dir::segmentPath, code, vertexCount,
+ column.ownerCount) : null;
+ PropertySpoolReader in = new PropertySpoolReader(column.spool.path(), ioBuffer)) {
+ long index = 0;
+ int owner = -1;
+ long ownerProperties = 0;
+ while (in.hasRemaining()) {
+ final int ordinal = in.readInt();
+ final ValueType valueType = in.readType();
+ if (valueType != ValueType.NULL) in.readPayload(valueType, value);
+ final ValueType idType = in.readType();
+ in.readPayload(idType, id);
+ if (multi && ordinal != owner) {
+ if (ownerProperties > 0) owners.owner(owner, ownerProperties);
+ owner = ordinal;
+ ownerProperties = 0;
+ }
+ ownerProperties++;
+ final long position = multi ? index : ordinal;
+ if (valueType == ValueType.NULL) {
+ writer.appendNullValueEncoded(position, idType, id.bytes, 0, id.length);
+ } else {
+ writer.appendEncoded(position, idType, id.bytes, 0, id.length, valueType, value.bytes, 0,
+ value.length);
+ }
+ index++;
+ }
+ segments.addAll(writer.finish());
+ if (multi) {
+ if (ownerProperties > 0) owners.owner(owner, ownerProperties);
+ segments.addAll(owners.finish(column.propertyCount));
+ }
+ }
+ vertexValueInfos.add(valueInfo);
+ vertexIdColumnInfos.add(idInfo);
+
+ // a meta column is indexed by vertex property, which in the single layout is the vertex ordinal when
+ // the parent column is dense and the entry index otherwise
+ final boolean byVertex = !multi && (valueInfo.encoding() == ColumnEncoding.DENSE_FIXED
+ || valueInfo.encoding() == ColumnEncoding.DENSE_VARIABLE);
+ final long metaElements = byVertex ? vertexCount : column.propertyCount;
+ final Map<Integer, Manifest.ColumnInfo> metaInfos = new LinkedHashMap<>();
+ for (final Map.Entry<Integer, MetaColumn> entry : column.metas.entrySet()) {
+ metaInfos.put(entry.getKey(), compactMeta(code, entry.getKey(), entry.getValue(), metaElements,
+ byVertex));
+ }
+ vertexKeyInfos.add(new Manifest.VertexKeyInfo(
+ multi ? Manifest.PropertyLayout.MULTI : Manifest.PropertyLayout.SINGLE,
+ multi ? OwnerWriter.encodingOf(vertexCount, column.ownerCount) : null, column.propertyCount,
+ metaInfos));
+ }
+ }
+
+ private Manifest.ColumnInfo compactMeta(final int keyCode, final int metaCode, final MetaColumn meta,
+ final long elementCount, final boolean byVertex) {
+ final PropertySpoolReader.Payload value = new PropertySpoolReader.Payload();
+ final Manifest.ColumnInfo info = meta.values.toInfo(elementCount);
+ try (ColumnWriter writer = ColumnWriter.create(dir::segmentPath,
+ SegmentPaths.vertexPropertyMetaDir(keyCode, metaCode), elementCount, info);
+ PropertySpoolReader in = new PropertySpoolReader(meta.spool.path(), ioBuffer)) {
+ while (in.hasRemaining()) {
+ final long index = in.readLong();
+ final int ordinal = in.readInt();
+ final ValueType type = in.readType();
+ final long position = byVertex ? ordinal : index;
+ if (type == ValueType.NULL) {
+ writer.appendNull(position);
+ } else {
+ in.readPayload(type, value);
+ writer.appendEncoded(position, type, value.bytes, 0, value.length);
+ }
+ }
+ segments.addAll(writer.finish());
+ }
+ return info;
+ }
+
+ private void compactEdgeProperties() {
+ final PropertySpoolReader.Payload value = new PropertySpoolReader.Payload();
+ for (int code = 0; code < edgeColumns.size(); code++) {
+ final KeyColumn column = edgeColumns.get(code);
+ final Manifest.ColumnInfo info = column.values.toInfo(edgeCount);
+ try (ColumnWriter writer = ColumnWriter.create(dir::segmentPath, SegmentPaths.edgePropertyDir(code),
+ edgeCount, info);
+ PropertySpoolReader in = new PropertySpoolReader(column.spool.path(), ioBuffer)) {
+ while (in.hasRemaining()) {
+ final int ordinal = in.readInt();
+ final ValueType type = in.readType();
+ if (type == ValueType.NULL) {
+ writer.appendNull(ordinal);
+ } else {
+ in.readPayload(type, value);
+ writer.appendEncoded(ordinal, type, value.bytes, 0, value.length);
+ }
+ }
+ segments.addAll(writer.finish());
+ }
+ edgeValueInfos.add(info);
+ }
+ }
+
+ private void compactVariables() {
+ final int count = variableValues.size();
+ if (count == 0) return;
+ variablesInfo = variableStats.toInfo(count);
+ try (ColumnWriter writer = ColumnWriter.create(dir::segmentPath, SegmentPaths.VARIABLES_DIR, count,
+ variablesInfo)) {
+ for (int code = 0; code < count; code++) writer.append(code, variableValues.get(code));
+ segments.addAll(writer.finish());
+ }
+ }
+
+ // ------------------------------------------------------------ manifest
+
+ private void fillManifest(final Manifest manifest, final SourceVersion version) {
+ manifest.setSourceId(version.sourceId());
+ manifest.setSourceVersion(version.version());
+ manifest.setLayout(layout);
+ manifest.setVertexCount(vertexCount);
+ manifest.setEdgeCount(edgeCount);
+ if (withIdentity) {
+ manifest.setVertexLabels(new ArrayList<>(vertexLabels.labels()));
+ manifest.setEdgeLabels(new ArrayList<>(edgeLabels.labels()));
+ manifest.setVertexLabelCounts(vertexLabelHistogram.toArray(vertexLabels.size()));
+ manifest.setEdgeLabelCounts(edgeLabelHistogram.toArray(edgeLabels.size()));
+ manifest.setVertexLabelLayout(multiLabels ? Manifest.VertexLabelLayout.MULTI
+ : Manifest.VertexLabelLayout.SINGLE);
+ manifest.setVertexIds(vertexIdInfo);
+ manifest.setEdgeIds(edgeIdInfo);
+ manifest.setVertexIdIndexExact(IdentifierIndex.exactType(vertexIdInfo) != null);
+ manifest.setEdgeIdIndexExact(IdentifierIndex.exactType(edgeIdInfo) != null);
+ }
+ if (withProperties) {
+ manifest.setVertexKeys(new ArrayList<>(vertexKeys.labels()));
+ manifest.setEdgeKeys(new ArrayList<>(edgeKeys.labels()));
+ manifest.setVertexProperties(vertexValueInfos);
+ manifest.setVertexPropertyIds(vertexIdColumnInfos);
+ manifest.setEdgeProperties(edgeValueInfos);
+ manifest.setVertexKeyInfos(vertexKeyInfos);
+ manifest.setMetaKeys(new ArrayList<>(metaKeys.labels()));
+ manifest.setVariableKeys(new ArrayList<>(variableKeys.labels()));
+ manifest.setVariables(variablesInfo);
+ }
+ manifest.setSourceDefaults(source.defaults());
+ manifest.setOutEdgesImplicit(grouped);
+ manifest.setEdgeIdIndex(edgeIndex);
+ final List<Manifest.SegmentInfo> sorted = new ArrayList<>(segments);
+ sorted.sort(Comparator.comparing(Manifest.SegmentInfo::path));
+ manifest.setSegments(sorted);
+ }
+
+ private static Map<String, Long> segmentBytes(final Manifest manifest) {
+ final Map<String, Long> bytes = new LinkedHashMap<>();
+ for (final Manifest.SegmentInfo s : manifest.getSegments()) {
+ bytes.put(s.path(), SegmentHeader.SIZE + s.width() * s.count());
+ }
+ return bytes;
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/BuildDirectory.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/BuildDirectory.java
new file mode 100644
index 0000000000..0b9615db9f
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/BuildDirectory.java
@@ -0,0 +1,264 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * A snapshot under construction. A build runs in a sibling directory named {@code <target>.tmp-<uuid>}, so the final
+ * move stays on one filesystem. {@link #publish(Manifest)} deletes the scratch directory, writes the manifest last,
+ * and moves the build directory to the target with {@code ATOMIC_MOVE}, falling back to a plain move when the
+ * filesystem does not support it. The target must not exist, both when the build starts and when it is published.
+ * <p/>
+ * Scratch files default to {@code <build>/scratch}. When a scratch directory is supplied, for example from
+ * {@code BuildOptions.scratchDirectory()}, a unique subdirectory is created inside it and only that subdirectory is
+ * ever deleted. Either way {@link #deleteScratch()} and {@link #publish(Manifest)} remove it.
+ * <p/>
+ * Use in a try-with-resources block: {@link #close()} deletes the build and scratch directories unless the build was
+ * published, so a failed build leaves nothing behind. I/O failures are reported as {@link UncheckedIOException}.
+ */
+public final class BuildDirectory implements AutoCloseable {
+
+ private static final String TEMP_SUFFIX = ".tmp-";
+
+ private final Path target;
+ private final Path buildDir;
+ private final Path scratchDir;
+ private boolean published;
+
+ private BuildDirectory(final Path target, final Path buildDir, final Path scratchDir) {
+ this.target = target;
+ this.buildDir = buildDir;
+ this.scratchDir = scratchDir;
+ }
+
+ /**
+ * Starts a build with scratch space inside the build directory.
+ *
+ * @param target the final snapshot directory, which must not exist
+ * @throws UncheckedIOException wrapping {@link FileAlreadyExistsException} if the target exists
+ */
+ public static BuildDirectory create(final Path target) {
+ return create(target, null);
+ }
+
+ /**
+ * Starts a build.
+ *
+ * @param target the final snapshot directory, which must not exist
+ * @param scratchDirectory a directory to hold scratch files, or null for {@code <build>/scratch}. It is created
+ * if missing and is not deleted; a unique subdirectory of it is used instead.
+ * @throws UncheckedIOException wrapping {@link FileAlreadyExistsException} if the target exists
+ */
+ public static BuildDirectory create(final Path target, final Path scratchDirectory) {
+ final Path absolute = target.toAbsolutePath().normalize();
+ try {
+ if (Files.exists(absolute)) throw new FileAlreadyExistsException(absolute.toString());
+ final Path parent = absolute.getParent();
+ if (parent != null) Files.createDirectories(parent);
+ final String id = UUID.randomUUID().toString();
+ final Path buildDir = absolute.resolveSibling(absolute.getFileName() + TEMP_SUFFIX + id);
+ Files.createDirectory(buildDir);
+ final Path scratch;
+ try {
+ if (scratchDirectory == null) {
+ scratch = buildDir.resolve(SegmentPaths.SCRATCH_DIR);
+ Files.createDirectory(scratch);
+ } else {
+ Files.createDirectories(scratchDirectory);
+ scratch = Files.createDirectory(scratchDirectory.toAbsolutePath()
+ .resolve(absolute.getFileName() + ".scratch-" + id));
+ }
+ } catch (IOException | RuntimeException e) {
+ deleteRecursively(buildDir);
+ throw e;
+ }
+ return new BuildDirectory(absolute, buildDir, scratch);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * The final snapshot directory.
+ */
+ public Path target() {
+ return target;
+ }
+
+ /**
+ * The temporary build directory, the root of the bundle while it is being built.
+ */
+ public Path path() {
+ return buildDir;
+ }
+
+ /**
+ * The scratch directory. It no longer exists after {@link #deleteScratch()}.
+ */
+ public Path scratch() {
+ return scratchDir;
+ }
+
+ /**
+ * Resolves a bundle-relative path, using {@code '/'} separators, against the build directory and creates its
+ * parent directories.
+ */
+ public Path segmentPath(final String relativePath) {
+ return resolveCreatingParents(buildDir, relativePath);
+ }
+
+ /**
+ * Resolves a scratch-relative path, using {@code '/'} separators, against the scratch directory and creates its
+ * parent directories.
+ */
+ public Path scratchPath(final String relativePath) {
+ return resolveCreatingParents(scratchDir, relativePath);
+ }
+
+ /**
+ * The total size in bytes of all files currently under the build directory and the scratch directory. Counts the
+ * apparent file length, so a sparse mapped segment is counted at its full size. Intended for
+ * {@code BuildStats} phase records.
+ */
+ public long bytesOnDisk() {
+ final AtomicLong total = new AtomicLong();
+ sumSizes(buildDir, total);
+ if (!scratchDir.startsWith(buildDir)) sumSizes(scratchDir, total);
+ return total.get();
+ }
+
+ /**
+ * Deletes the scratch directory and everything in it. Idempotent.
+ */
+ public void deleteScratch() {
+ deleteRecursively(scratchDir);
+ }
+
+ /**
+ * Completes the build: deletes the scratch directory, writes {@code manifest.json} last, and moves the build
+ * directory to the target, atomically where the filesystem supports it.
+ *
+ * @return the target path
+ * @throws UncheckedIOException wrapping {@link FileAlreadyExistsException} if the target now exists
+ */
+ public Path publish(final Manifest manifest) {
+ if (published) throw new IllegalStateException("Already published: " + target);
+ deleteScratch();
+ ManifestIO.write(manifest, buildDir.resolve(SegmentPaths.MANIFEST));
+ try {
+ if (Files.exists(target)) throw new FileAlreadyExistsException(target.toString());
+ try {
+ Files.move(buildDir, target, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException e) {
+ Files.move(buildDir, target);
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ published = true;
+ return target;
+ }
+
+ /**
+ * Deletes the build and scratch directories unless the build was published. Idempotent.
+ */
+ @Override
+ public void close() {
+ if (published) return;
+ deleteScratch();
+ deleteRecursively(buildDir);
+ }
+
+ private static Path resolveCreatingParents(final Path root, final String relativePath) {
+ Path p = root;
+ for (final String part : relativePath.split(SegmentPaths.SEPARATOR)) {
+ if (part.isEmpty() || part.equals(".") || part.equals("..")) {
+ throw new IllegalArgumentException("Invalid relative path: " + relativePath);
+ }
+ p = p.resolve(part);
+ }
+ try {
+ final Path parent = p.getParent();
+ if (parent != null) Files.createDirectories(parent);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ return p;
+ }
+
+ private static void sumSizes(final Path dir, final AtomicLong total) {
+ if (!Files.exists(dir)) return;
+ try {
+ Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
+ @Override
+ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) {
+ total.addAndGet(attrs.size());
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFileFailed(final Path file, final IOException exc) {
+ // a file may be deleted while walking; it no longer uses space
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Deletes a file or directory tree. A missing path is not an error.
+ */
+ static void deleteRecursively(final Path root) {
+ try {
+ Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
+ @Override
+ public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
+ Files.deleteIfExists(file);
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult postVisitDirectory(final Path dir, final IOException exc) throws IOException {
+ if (exc != null) throw exc;
+ Files.deleteIfExists(dir);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ } catch (NoSuchFileException e) {
+ // already gone
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnCursor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnCursor.java
new file mode 100644
index 0000000000..5c1da889c2
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnCursor.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+/**
+ * Looks up the entries of a {@link ColumnReader} for ascending element ordinals. A sparse column is advanced
+ * sequentially through {@code ordinals.bin}, so scanning a whole batch costs one pass instead of a binary search per
+ * ordinal; a dense column answers directly. An ordinal below the previous one is still answered correctly, by a binary
+ * search that repositions the cursor. Instances are stateful and not thread-safe; use one per scan.
+ */
+public final class ColumnCursor {
+
+ private final ColumnReader reader;
+ // sparse columns: the entry index of the first element not below the last requested ordinal
+ private long position;
+ private long lastOrdinal = -1;
+
+ ColumnCursor(final ColumnReader reader) {
+ this.reader = reader;
+ }
+
+ public ColumnReader reader() {
+ return reader;
+ }
+
+ /**
+ * The entry index of the element, or -1 when it has no value; see {@link ColumnReader#entryIndex(long)}.
+ */
+ public long seek(final long ordinal) {
+ if (reader.isDense()) return reader.entryIndex(ordinal);
+ if (ordinal < lastOrdinal) {
+ final long entry = reader.entryIndex(ordinal);
+ lastOrdinal = ordinal;
+ position = entry >= 0 ? entry : lowerBound(ordinal);
+ return entry;
+ }
+ lastOrdinal = ordinal;
+ final long count = reader.entryCount();
+ while (position < count && reader.ordinalAt(position) < ordinal) position++;
+ return position < count && reader.ordinalAt(position) == ordinal ? position : -1;
+ }
+
+ // the entry index of the first element whose ordinal is not below the given one
+ private long lowerBound(final long ordinal) {
+ long lo = 0;
+ long hi = reader.entryCount();
+ while (lo < hi) {
+ final long mid = (lo + hi) >>> 1;
+ if (reader.ordinalAt(mid) < ordinal) lo = mid + 1;
+ else hi = mid;
+ }
+ return lo;
+ }
+
+ /**
+ * Whether the element has a value, see {@link ColumnReader#isPresent(long)}.
+ */
+ public boolean isPresent(final long ordinal) {
+ return seek(ordinal) >= 0;
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnEncoding.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnEncoding.java
new file mode 100644
index 0000000000..1bbb6b14cc
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnEncoding.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+/**
+ * How a column directory stores its entries. A column is dense when {@code presentCount * 4 >= elementCount} and sparse
+ * otherwise. Mixed-type columns use a variable-width encoding plus a {@code types.bin} segment.
+ */
+public enum ColumnEncoding {
+
+ /**
+ * Dense, single fixed-width type: optional {@code presence.bin} and {@code values.bin}.
+ */
+ DENSE_FIXED,
+
+ /**
+ * Sparse, single fixed-width type: {@code ordinals.bin} and {@code values.bin}.
+ */
+ SPARSE_FIXED,
+
+ /**
+ * Dense, variable-width or mixed types: optional {@code presence.bin}, {@code offsets.bin} and {@code data.bin}.
+ */
+ DENSE_VARIABLE,
+
+ /**
+ * Sparse, variable-width or mixed types: {@code ordinals.bin}, {@code offsets.bin} and {@code data.bin}.
+ */
+ SPARSE_VARIABLE
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnReader.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnReader.java
new file mode 100644
index 0000000000..d158f4e040
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnReader.java
@@ -0,0 +1,492 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * Random access to one column directory written by {@link ColumnWriter}, through read-only memory-mapped segments.
+ * {@link #get(long)} decodes the value of an element and {@link #type(long)} returns its {@link ValueType}; both return
+ * null for an element that has no entry and for an element whose entry is a null value, so callers that must tell the
+ * two apart use {@link #isPresent(long)} and {@link #isNull(long)}.
+ * <p/>
+ * The entry-index methods ({@link #getAt}, {@link #typeAt}, {@link #isNullAt}) address the column's entries directly,
+ * which is how the multi vertex-property layout reads a key's values by vertex-property ordinal: there the column is
+ * dense and complete, so the entry index is the ordinal. {@link #entryCount()} is the number of entries. Reading an
+ * absent position of a dense column by entry index is not meaningful.
+ * <p/>
+ * A dense column answers in constant time. A sparse column binary-searches {@code ordinals.bin}. Instances may be
+ * used from several threads.
+ * <p/>
+ * The segments are obtained through a {@code Function<String, MappedSegment>} from a path relative to the bundle root,
+ * so the snapshot reader can validate headers and share its mappings; see {@link #readOnlyOpener(Path)} for the
+ * plain implementation. The segment headers are checked against what the {@link Manifest.ColumnInfo} implies, and a
+ * mismatch fails loudly with {@link IllegalStateException}.
+ */
+public final class ColumnReader implements AutoCloseable {
+
+ private final String relativeDir;
+ private final long elementCount;
+ private final Manifest.ColumnInfo info;
+ private final boolean dense;
+ private final boolean fixed;
+ private final boolean mixed;
+ private final ValueType singleType;
+ private final MappedSegment presence;
+ private final MappedSegment ordinals;
+ private final MappedSegment values;
+ private final MappedSegment offsets;
+ private final MappedSegment data;
+ private final MappedSegment types;
+ private final MappedSegment nulls;
+ private final List<MappedSegment> segments = new ArrayList<>();
+
+ private ColumnReader(final Function<String, MappedSegment> opener, final String relativeDir,
+ final long elementCount, final Manifest.ColumnInfo info, final ColumnReader parent) {
+ this.relativeDir = relativeDir;
+ this.elementCount = elementCount;
+ this.info = info;
+ this.dense = ColumnWriter.isDense(info.encoding());
+ this.fixed = ColumnWriter.isFixed(info.encoding());
+ this.mixed = info.valueTypes().size() > 1;
+ this.singleType = info.valueTypes().size() == 1 ? info.valueTypes().get(0) : null;
+ if (fixed && (singleType == null || !singleType.isFixedWidth())) {
+ throw corrupt("encoding " + info.encoding() + " requires exactly one fixed-width type, found "
+ + info.valueTypes());
+ }
+ final long present = info.presentCount();
+ if (present < 0 || present > elementCount) {
+ throw corrupt("present count " + present + " exceeds the element count " + elementCount);
+ }
+ if (dense != ColumnStats.isDense(elementCount, present)) {
+ throw corrupt("encoding " + info.encoding() + " does not match " + present + " of " + elementCount
+ + " elements");
+ }
+ if (parent == null) {
+ if (dense && present < elementCount) {
+ presence = map(opener, SegmentPaths.PRESENCE, 8, (elementCount + 63) >>> 6);
+ } else {
+ presence = null;
+ }
+ ordinals = dense ? null : map(opener, SegmentPaths.ORDINALS, 4, present);
+ } else {
+ if (parent.elementCount != elementCount || parent.info.presentCount() != present
+ || parent.dense != dense) {
+ throw corrupt("does not match the shape of its parent column " + parent.relativeDir);
+ }
+ presence = parent.presence;
+ ordinals = parent.ordinals;
+ }
+ final long entries = dense ? elementCount : present;
+ if (fixed) {
+ values = map(opener, SegmentPaths.VALUES, singleType.width(), entries);
+ offsets = null;
+ data = null;
+ } else {
+ values = null;
+ offsets = map(opener, SegmentPaths.OFFSETS, 8, entries + 1);
+ data = map(opener, SegmentPaths.DATA, 1, offsets.getLong(entries));
+ }
+ types = mixed ? map(opener, SegmentPaths.TYPES, 1, entries) : null;
+ if (info.nullCount() < 0 || info.nullCount() > present) {
+ throw corrupt("null count " + info.nullCount() + " exceeds the present count " + present);
+ }
+ nulls = info.nullCount() > 0 ? map(opener, SegmentPaths.NULLS, 8, (entries + 63) >>> 6) : null;
+ }
+
+ private MappedSegment map(final Function<String, MappedSegment> opener, final String fileName, final int width,
+ final long count) {
+ final String path = SegmentPaths.file(relativeDir, fileName);
+ final MappedSegment seg = opener.apply(path);
+ if (seg.valueWidth() != width || seg.count() != count) {
+ throw corrupt(path + " has width " + seg.valueWidth() + " and count " + seg.count() + ", expected width "
+ + width + " and count " + count);
+ }
+ segments.add(seg);
+ return seg;
+ }
+
+ private IllegalStateException corrupt(final String message) {
+ return new IllegalStateException("Column " + relativeDir + ": " + message);
+ }
+
+ /**
+ * An opener that maps {@code root/<relativePath>} read-only with the default region size. Each call maps the file
+ * again, so a caller that wants to share or track mappings should supply its own function.
+ */
+ public static Function<String, MappedSegment> readOnlyOpener(final Path root) {
+ return readOnlyOpener(root, MappedSegment.DEFAULT_REGION_BYTES);
+ }
+
+ /**
+ * As {@link #readOnlyOpener(Path)} with an explicit region size.
+ */
+ public static Function<String, MappedSegment> readOnlyOpener(final Path root, final long regionBytes) {
+ return relativePath -> MappedSegment.open(root.resolve(relativePath), MappedSegment.Mode.READ_ONLY,
+ regionBytes);
+ }
+
+ /**
+ * Opens a column.
+ *
+ * @param opener maps a bundle-relative segment path, for example {@link #readOnlyOpener(Path)}
+ * @param relativeDir the column directory relative to the bundle root
+ * @param elementCount the number of elements of the column's kind: all vertices, or all edges
+ * @param info the column's manifest entry
+ * @throws IllegalStateException if a segment does not match what {@code info} implies
+ */
+ public static ColumnReader open(final Function<String, MappedSegment> opener, final String relativeDir,
+ final long elementCount, final Manifest.ColumnInfo info) {
+ return new ColumnReader(opener, relativeDir, elementCount, info, null);
+ }
+
+ /**
+ * Opens a companion column, such as a vertex-property identifier column, that shares {@code presence.bin} or
+ * {@code ordinals.bin} with its parent. The parent must stay open while the companion is used, and closing either
+ * does nothing to the other's shared segments.
+ */
+ public static ColumnReader openCompanion(final Function<String, MappedSegment> opener, final String relativeDir,
+ final ColumnReader parent, final Manifest.ColumnInfo info) {
+ Objects.requireNonNull(parent);
+ return new ColumnReader(opener, relativeDir, parent.elementCount, info, parent);
+ }
+
+ public Manifest.ColumnInfo info() {
+ return info;
+ }
+
+ /**
+ * The number of elements the column covers, present or not.
+ */
+ public long elementCount() {
+ return elementCount;
+ }
+
+ public long presentCount() {
+ return info.presentCount();
+ }
+
+ /**
+ * The number of entries in the value files: the element count of a dense column and the present count of a sparse
+ * one. Entry indexes run from 0 to this count exclusive.
+ */
+ public long entryCount() {
+ return dense ? elementCount : info.presentCount();
+ }
+
+ /**
+ * The segments this reader maps itself, that is excluding those shared with a parent.
+ */
+ public List<MappedSegment> segments() {
+ return segments;
+ }
+
+ /**
+ * The index of the element's entry in the value files, or -1 when the element has no value. This is the element
+ * ordinal for a dense column and the rank among present elements for a sparse one.
+ *
+ * @throws IndexOutOfBoundsException if the ordinal is not in {@code [0, elementCount)}
+ */
+ public long entryIndex(final long ordinal) {
+ Objects.checkIndex(ordinal, elementCount);
+ if (dense) {
+ if (presence == null) return ordinal;
+ return (presence.getLong(ordinal >>> 6) & (1L << (ordinal & 63))) != 0 ? ordinal : -1;
+ }
+ int lo = 0;
+ int hi = (int) info.presentCount() - 1;
+ while (lo <= hi) {
+ final int mid = (lo + hi) >>> 1;
+ final int v = ordinals.getInt(mid);
+ if (v < ordinal) {
+ lo = mid + 1;
+ } else if (v > ordinal) {
+ hi = mid - 1;
+ } else {
+ return mid;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Whether the element has a value.
+ */
+ public boolean isPresent(final long ordinal) {
+ return entryIndex(ordinal) >= 0;
+ }
+
+ /**
+ * Whether the element has an entry that is a null value. False for an element with no entry.
+ */
+ public boolean isNull(final long ordinal) {
+ final long entry = entryIndex(ordinal);
+ return entry >= 0 && isNullAt(entry);
+ }
+
+ /**
+ * Whether the entry is a null value.
+ *
+ * @throws IndexOutOfBoundsException if the entry index is not in {@code [0, entryCount())}
+ */
+ public boolean isNullAt(final long entryIndex) {
+ Objects.checkIndex(entryIndex, entryCount());
+ return nulls != null && (nulls.getLong(entryIndex >>> 6) & (1L << (entryIndex & 63))) != 0;
+ }
+
+ /**
+ * The type of the element's value, or null when it has none or it is null.
+ */
+ public ValueType type(final long ordinal) {
+ final long entry = entryIndex(ordinal);
+ return entry < 0 ? null : typeAt(entry);
+ }
+
+ /**
+ * The type of the entry's value, or null for a null value. For a dense column the entry of an absent element has
+ * no meaningful type, and is null in a mixed-type column.
+ *
+ * @throws IndexOutOfBoundsException if the entry index is not in {@code [0, entryCount())}
+ */
+ public ValueType typeAt(final long entryIndex) {
+ if (isNullAt(entryIndex)) return null;
+ return typeOfEntry(entryIndex);
+ }
+
+ /**
+ * The element's decoded value, or null when it has none or it is null.
+ */
+ public Object get(final long ordinal) {
+ final long entry = entryIndex(ordinal);
+ return entry < 0 ? null : getAt(entry);
+ }
+
+ /**
+ * The entry's decoded value, or null for a null value.
+ *
+ * @throws IndexOutOfBoundsException if the entry index is not in {@code [0, entryCount())}
+ */
+ public Object getAt(final long entryIndex) {
+ final ValueType type = typeAt(entryIndex);
+ if (type == null) return null;
+ if (fixed) return ValueCodec.fixedValue(type, fixedBits(entryIndex));
+ final byte[] bytes = variableBytes(entryIndex);
+ return ValueCodec.decode(type, bytes, 0, bytes.length);
+ }
+
+ /**
+ * The element's canonical encoded bytes as produced by {@link ValueCodec#encode(ValueType, Object)}, or null when
+ * it has none or it is null. Use {@link #type(long)} for the type.
+ */
+ public byte[] encoded(final long ordinal) {
+ final long entry = entryIndex(ordinal);
+ if (entry < 0 || isNullAt(entry)) return null;
+ return fixed ? ValueCodec.fixedBitsToBytes(singleType, fixedBits(entry)) : variableBytes(entry);
+ }
+
+ /**
+ * Whether the element has a value of the given type with exactly the given encoded bytes. This is the identity
+ * comparison used by non-exact identifier indexes. A null value matches nothing.
+ */
+ public boolean matches(final long ordinal, final ValueType type, final byte[] encoded) {
+ final long entry = entryIndex(ordinal);
+ if (entry < 0 || typeAt(entry) != type) return false;
+ if (fixed) return Arrays.equals(ValueCodec.fixedBitsToBytes(singleType, fixedBits(entry)), encoded);
+ return Arrays.equals(variableBytes(entry), encoded);
+ }
+
+ /**
+ * Whether both elements have a value of the same type with the same encoded bytes.
+ */
+ public boolean sameValue(final long ordinalA, final long ordinalB) {
+ final ValueType type = type(ordinalA);
+ return type != null && matches(ordinalB, type, encoded(ordinalA));
+ }
+
+ // ---------------------------------------------------------------- primitive access for native execution
+
+ /**
+ * The one value type of the column, or null when it is mixed or holds no values.
+ */
+ public ValueType singleType() {
+ return singleType;
+ }
+
+ /**
+ * Whether values are stored fixed-width, so {@link #rawBitsAt(long)} applies.
+ */
+ public boolean isFixed() {
+ return fixed;
+ }
+
+ /**
+ * Whether the column has an entry for every element position ({@code entryIndex == ordinal}); a dense column may
+ * still have absent positions, see {@link #hasPresenceWords()}.
+ */
+ public boolean isDense() {
+ return dense;
+ }
+
+ /**
+ * Whether the column holds more than one value type and therefore has a {@code types.bin}.
+ */
+ public boolean isMixed() {
+ return mixed;
+ }
+
+ /**
+ * Whether some entry is a null value, that is whether {@link #nullWord(long)} can return a nonzero word.
+ */
+ public boolean hasNulls() {
+ return nulls != null;
+ }
+
+ /**
+ * Whether the column is dense and not every element has a value, so {@link #presenceWord(long)} reads a bitmap. A
+ * dense column without a bitmap has every element present, and a sparse column has no bitmap.
+ */
+ public boolean hasPresenceWords() {
+ return presence != null;
+ }
+
+ /**
+ * The number of 64-bit words of the presence bitmap, {@code ceil(elementCount / 64)}. Meaningful for dense columns.
+ */
+ public long presenceWordCount() {
+ return (elementCount + 63) >>> 6;
+ }
+
+ /**
+ * A word of the presence bitmap of a dense column, in which bit {@code i} of word {@code w} is element
+ * {@code 64 * w + i}. Returns all ones, less the bits beyond the element count, when every element is present.
+ *
+ * @throws UnsupportedOperationException for a sparse column
+ * @throws IndexOutOfBoundsException if the word index is not in {@code [0, presenceWordCount())}
+ */
+ public long presenceWord(final long wordIndex) {
+ if (!dense) throw new UnsupportedOperationException("Column " + relativeDir + " is sparse");
+ Objects.checkIndex(wordIndex, presenceWordCount());
+ if (presence != null) return presence.getLong(wordIndex);
+ final long remaining = elementCount - (wordIndex << 6);
+ return remaining >= 64 ? -1L : (1L << remaining) - 1;
+ }
+
+ /**
+ * A word of the null bitmap, in which bit {@code i} of word {@code w} is the entry {@code 64 * w + i}. Zero for
+ * every word when {@link #hasNulls()} is false.
+ *
+ * @throws IndexOutOfBoundsException if the word index is not in {@code [0, ceil(entryCount() / 64))}
+ */
+ public long nullWord(final long wordIndex) {
+ Objects.checkIndex(wordIndex, (entryCount() + 63) >>> 6);
+ return nulls == null ? 0 : nulls.getLong(wordIndex);
+ }
+
+ /**
+ * The raw stored bits of a fixed-width entry, sign-extended from the column's value width: the numeric value for
+ * {@code BYTE}, {@code SHORT}, {@code INT} and {@code LONG}, the character for {@code CHAR}, {@code 0} or
+ * {@code 1} for {@code BOOLEAN}, and the IEEE bits for {@code FLOAT} (low 32 bits) and {@code DOUBLE}. The bits of
+ * a null entry and of an absent position of a dense column are zero.
+ *
+ * @throws UnsupportedOperationException if the column is not {@link #isFixed() fixed-width}
+ * @throws IndexOutOfBoundsException if the entry index is not in {@code [0, entryCount())}
+ */
+ public long rawBitsAt(final long entryIndex) {
+ if (!fixed) throw new UnsupportedOperationException("Column " + relativeDir + " is not fixed-width");
+ return fixedBits(entryIndex);
+ }
+
+ /**
+ * The element ordinal of an entry: the entry index itself for a dense column and the element with the
+ * {@code entryIndex}th value for a sparse one.
+ *
+ * @throws IndexOutOfBoundsException if the entry index is not in {@code [0, entryCount())}
+ */
+ public long ordinalAt(final long entryIndex) {
+ Objects.checkIndex(entryIndex, entryCount());
+ return dense ? entryIndex : ordinals.getInt(entryIndex);
+ }
+
+ /**
+ * The entry's canonical encoded bytes, see {@link #encoded(long)}, or null for a null value.
+ *
+ * @throws IndexOutOfBoundsException if the entry index is not in {@code [0, entryCount())}
+ */
+ public byte[] encodedAt(final long entryIndex) {
+ if (isNullAt(entryIndex)) return null;
+ return fixed ? ValueCodec.fixedBitsToBytes(singleType, fixedBits(entryIndex)) : variableBytes(entryIndex);
+ }
+
+ /**
+ * A cursor for looking up ascending ordinals without binary searches.
+ */
+ public ColumnCursor cursor() {
+ return new ColumnCursor(this);
+ }
+
+ private ValueType typeOfEntry(final long entry) {
+ if (!mixed) return singleType;
+ final byte code = types.getByte(entry);
+ if (code == 0) return null;
+ final ValueType type = ValueCodec.typeOfCode(code);
+ if (type == null) throw corrupt("entry " + entry + " has an invalid type code");
+ return type;
+ }
+
+ // only the low singleType.width() bytes are significant
+ private long fixedBits(final long entry) {
+ switch (singleType.width()) {
+ case 1:
+ return values.getByte(entry);
+ case 2:
+ return values.getShort(entry);
+ case 4:
+ return values.getInt(entry);
+ default:
+ return values.getLong(entry);
+ }
+ }
+
+ private byte[] variableBytes(final long entry) {
+ final long start = offsets.getLong(entry);
+ final long end = offsets.getLong(entry + 1);
+ final byte[] out = new byte[Math.toIntExact(end - start)];
+ data.getBytes(start, out, 0, out.length);
+ return out;
+ }
+
+ /**
+ * Recomputes the checksum of every segment this reader maps itself.
+ *
+ * @throws java.io.UncheckedIOException if one does not match its header
+ */
+ public void verifyChecksums() {
+ for (final MappedSegment s : segments) s.verifyChecksum();
+ }
+
+ @Override
+ public void close() {
+ for (final MappedSegment s : segments) s.close();
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnStats.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnStats.java
new file mode 100644
index 0000000000..a0552f3861
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnStats.java
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Statistics gathered for one column during a scan: how many elements have an entry, how many of those entries are
+ * null, and which value types were observed. Once the scan is complete and the element count is known, {@link #toInfo(long)} applies the encoding
+ * rules shared by both builders:
+ * <ul>
+ * <li>a column is dense when {@code presentCount * 4 >= elementCount} and sparse otherwise;</li>
+ * <li>a column of exactly one fixed-width type uses the fixed layout;</li>
+ * <li>every other column, that is a single variable-width type, mixed types, or no values at all, uses the
+ * variable layout, and a mixed column additionally has a {@code types.bin} segment.</li>
+ * </ul>
+ * Call {@link #add(ValueType)} once per present non-null value and {@link #addNull()} once per present null value. A
+ * null is an entry that counts towards the present count, so it takes a position in the column, but it adds no value
+ * type: a column whose only entries are nulls has no types and uses the variable layout, which costs an empty range
+ * per entry. The null count is recorded in the {@link Manifest.ColumnInfo} and decides whether the column has a
+ * {@code nulls.bin}. Instances are not thread-safe.
+ */
+public final class ColumnStats {
+
+ private long presentCount;
+ private long nullCount;
+ private int typeMask;
+ // integral range, tracked only for values added with their value
+ private long rangedCount;
+ private long min = Long.MAX_VALUE;
+ private long max = Long.MIN_VALUE;
+
+ /**
+ * Records one present value of the given type.
+ */
+ public void add(final ValueType type) {
+ if (type == ValueType.NULL) throw new IllegalArgumentException("Use addNull() for null values");
+ presentCount++;
+ typeMask |= 1 << type.code();
+ }
+
+ /**
+ * Records one present value of the given type and, when the type is integral, folds the value into the range
+ * reported by {@link Manifest.ColumnInfo#minValue()} and {@link Manifest.ColumnInfo#maxValue()}. A column
+ * reports a range only if every non-null value was added through this method.
+ *
+ * @param value the non-null value, whose class is the one {@link ValueCodec#requireType} mapped to {@code type}
+ */
+ public void add(final ValueType type, final Object value) {
+ add(type);
+ if (ValueCodec.isIntegral(type)) {
+ final long v = ((Number) value).longValue();
+ rangedCount++;
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+ }
+
+ /**
+ * Records one present null value. It counts as present but adds no value type.
+ */
+ public void addNull() {
+ presentCount++;
+ nullCount++;
+ }
+
+ /**
+ * The number of null values recorded so far, which is also counted by {@link #presentCount()}.
+ */
+ public long nullCount() {
+ return nullCount;
+ }
+
+ /**
+ * The number of present entries recorded so far, including nulls.
+ */
+ public long presentCount() {
+ return presentCount;
+ }
+
+ /**
+ * The observed value types in ascending {@link ValueType#code()} order.
+ */
+ public List<ValueType> types() {
+ final List<ValueType> out = new ArrayList<>();
+ for (int code = 1; code < Integer.SIZE; code++) {
+ if ((typeMask & (1 << code)) != 0) out.add(ValueCodec.typeOfCode((byte) code));
+ }
+ return Collections.unmodifiableList(out);
+ }
+
+ /**
+ * Whether a column with the given counts is dense, that is {@code presentCount * 4 >= elementCount}.
+ */
+ public static boolean isDense(final long elementCount, final long presentCount) {
+ return presentCount * 4 >= elementCount;
+ }
+
+ /**
+ * The encoding chosen for the given counts and observed types.
+ */
+ public static ColumnEncoding chooseEncoding(final long elementCount, final long presentCount,
+ final List<ValueType> types) {
+ final boolean dense = isDense(elementCount, presentCount);
+ final boolean fixed = types.size() == 1 && types.get(0).isFixedWidth();
+ if (fixed) return dense ? ColumnEncoding.DENSE_FIXED : ColumnEncoding.SPARSE_FIXED;
+ return dense ? ColumnEncoding.DENSE_VARIABLE : ColumnEncoding.SPARSE_VARIABLE;
+ }
+
+ /**
+ * The manifest description of the column these statistics describe.
+ *
+ * @param elementCount the number of elements of the column's kind: all vertices, or all edges
+ * @throws IllegalStateException if more values were recorded than there are elements
+ */
+ public Manifest.ColumnInfo toInfo(final long elementCount) {
+ if (presentCount > elementCount) {
+ throw new IllegalStateException("Column has " + presentCount + " values for " + elementCount + " elements");
+ }
+ final List<ValueType> types = types();
+ Long minValue = null;
+ Long maxValue = null;
+ if (!types.isEmpty() && rangedCount == presentCount - nullCount) {
+ boolean integral = true;
+ for (final ValueType t : types) integral &= ValueCodec.isIntegral(t);
+ if (integral) {
+ minValue = min;
+ maxValue = max;
+ }
+ }
+ return new Manifest.ColumnInfo(chooseEncoding(elementCount, presentCount, types), types, presentCount, nullCount,
+ minValue, maxValue);
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnWriter.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnWriter.java
new file mode 100644
index 0000000000..7e9e4ce08c
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ColumnWriter.java
@@ -0,0 +1,437 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.UnsupportedSnapshotDataException;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Writes one column directory, see the Columns section of the spike document. It serves both builders because it only
+ * ever appends: entries are presented in strictly ascending ordinal order and the writer streams them through small
+ * buffers, so its heap use is independent of the column size. The materializing builder iterates its in-heap entries
+ * and the streaming builder replays its spool, and both make the same calls. The output depends only on the entries
+ * and the {@link Manifest.ColumnInfo}, so both builders produce identical bytes.
+ * <p/>
+ * The {@link Manifest.ColumnInfo} must be known before the first append, which is why both builders collect
+ * {@link ColumnStats} during the scan. The writer creates exactly the files its encoding needs:
+ * <ul>
+ * <li>dense: {@code presence.bin}, omitted when every element has a value;</li>
+ * <li>sparse: {@code ordinals.bin} (int32);</li>
+ * <li>fixed: {@code values.bin} with the value width;</li>
+ * <li>variable: {@code offsets.bin} (int64, entries + 1 of them) and {@code data.bin};</li>
+ * <li>mixed types: additionally {@code types.bin} with one type code per entry, 0 for an absent or null entry;</li>
+ * <li>at least one null value: additionally {@code nulls.bin}, a bitmap of int64 words like {@code presence.bin}
+ * with a bit per entry, that is per element of a dense column and per present value of a sparse one.</li>
+ * </ul>
+ * A dense column has one entry per element, with a zero value or an empty range at absent positions. A sparse column
+ * has one entry per present value. A null value is a present entry like any other, appended with {@link #appendNull}
+ * (or {@link #append} of null), that holds a zero value or an empty range and is marked in {@code nulls.bin}. The
+ * null count of the {@link Manifest.ColumnInfo} must equal the number of nulls appended.
+ * <p/>
+ * A <em>companion</em> column (see {@link #createCompanion}) writes only the value files and skips {@code presence.bin}
+ * and {@code ordinals.bin}. It is used for the vertex-property identifier column, which shares those files with its
+ * parent value column. See {@link VertexPropertyColumnWriter} for the combined writer.
+ * <p/>
+ * Values must be appended in strictly ascending ordinal order and the number of appended values, nulls included, must
+ * equal {@code info.presentCount()} when {@link #finish()} is called. I/O failures are {@code UncheckedIOException}.
+ * Instances are not thread-safe.
+ */
+public final class ColumnWriter implements AutoCloseable {
+
+ private final String relativeDir;
+ private final long elementCount;
+ private final Manifest.ColumnInfo info;
+ private final boolean dense;
+ private final boolean fixed;
+ private final boolean mixed;
+ private final int fixedWidth;
+ private final boolean[] allowed = new boolean[256];
+ private final List<Manifest.SegmentInfo> segments = new ArrayList<>();
+
+ private SegmentWriter presence;
+ private SegmentWriter ordinals;
+ private SegmentWriter values;
+ private SegmentWriter offsets;
+ private SegmentWriter data;
+ private SegmentWriter types;
+ private SegmentWriter nulls;
+
+ private long lastOrdinal = -1;
+ // dense columns: the next position whose entry has not been written to values/offsets/types
+ private long nextPosition;
+ private long dataLength;
+ private long appended;
+ // presence bitmap: words written so far and the pending word, which has index presenceWordsWritten
+ private long presenceWordsWritten;
+ private long presenceWord;
+ // null bitmap, same scheme as the presence bitmap but indexed by entry
+ private long nulled;
+ private long nullWordsWritten;
+ private long nullWord;
+ private boolean finished;
+
+ private ColumnWriter(final Function<String, Path> resolver, final String relativeDir, final long elementCount,
+ final Manifest.ColumnInfo info, final boolean companion) {
+ this.relativeDir = relativeDir;
+ this.elementCount = elementCount;
+ this.info = info;
+ this.dense = isDense(info.encoding());
+ this.fixed = isFixed(info.encoding());
+ this.mixed = info.valueTypes().size() > 1;
+ if (info.presentCount() < 0 || info.presentCount() > elementCount) {
+ throw new IllegalArgumentException("Column " + relativeDir + " has " + info.presentCount()
+ + " values for " + elementCount + " elements");
+ }
+ if (dense != ColumnStats.isDense(elementCount, info.presentCount())) {
+ throw new IllegalArgumentException("Column " + relativeDir + " encoding " + info.encoding()
+ + " does not match " + info.presentCount() + " of " + elementCount + " elements");
+ }
+ if (fixed && (info.valueTypes().size() != 1 || !info.valueTypes().get(0).isFixedWidth())) {
+ throw new IllegalArgumentException("Column " + relativeDir + " encoding " + info.encoding()
+ + " requires exactly one fixed-width type, found " + info.valueTypes());
+ }
+ if (info.nullCount() < 0 || info.nullCount() > info.presentCount()) {
+ throw new IllegalArgumentException("Column " + relativeDir + " has " + info.nullCount()
+ + " nulls among " + info.presentCount() + " values");
+ }
+ this.fixedWidth = fixed ? info.valueTypes().get(0).width() : 0;
+ for (final ValueType t : info.valueTypes()) allowed[t.code() & 0xff] = true;
+
+ try {
+ if (!companion) {
+ if (dense && info.presentCount() < elementCount) {
+ presence = open(resolver, SegmentPaths.PRESENCE, 8);
+ } else if (!dense) {
+ ordinals = open(resolver, SegmentPaths.ORDINALS, 4);
+ }
+ }
+ if (fixed) {
+ values = open(resolver, SegmentPaths.VALUES, fixedWidth);
+ } else {
+ offsets = open(resolver, SegmentPaths.OFFSETS, 8);
+ data = open(resolver, SegmentPaths.DATA, 1);
+ }
+ if (mixed) types = open(resolver, SegmentPaths.TYPES, 1);
+ if (info.nullCount() > 0) nulls = open(resolver, SegmentPaths.NULLS, 8);
+ } catch (RuntimeException e) {
+ closeQuietly();
+ throw e;
+ }
+ }
+
+ private SegmentWriter open(final Function<String, Path> resolver, final String fileName, final int width) {
+ return SegmentWriter.create(resolver.apply(SegmentPaths.file(relativeDir, fileName)), width);
+ }
+
+ /**
+ * Creates a column writer.
+ *
+ * @param resolver maps a path relative to the bundle root (or the scratch root) to a file, creating missing
+ * parent directories, for example {@code BuildDirectory::segmentPath}
+ * @param relativeDir the column directory relative to that root, for example
+ * {@link SegmentPaths#vertexPropertyDir(int)}; also recorded in the segment infos
+ * @param elementCount the number of elements of the column's kind: all vertices, or all edges
+ * @param info the encoding, value types and present count, normally from {@link ColumnStats#toInfo(long)}
+ */
+ public static ColumnWriter create(final Function<String, Path> resolver, final String relativeDir,
+ final long elementCount, final Manifest.ColumnInfo info) {
+ return new ColumnWriter(resolver, relativeDir, elementCount, info, false);
+ }
+
+ /**
+ * Creates a companion column writer that writes no {@code presence.bin} or {@code ordinals.bin}. The caller is
+ * responsible for the parent column having the same present count, element count and positions.
+ */
+ public static ColumnWriter createCompanion(final Function<String, Path> resolver, final String relativeDir,
+ final long elementCount, final Manifest.ColumnInfo info) {
+ return new ColumnWriter(resolver, relativeDir, elementCount, info, true);
+ }
+
+ static boolean isDense(final ColumnEncoding encoding) {
+ return encoding == ColumnEncoding.DENSE_FIXED || encoding == ColumnEncoding.DENSE_VARIABLE;
+ }
+
+ static boolean isFixed(final ColumnEncoding encoding) {
+ return encoding == ColumnEncoding.DENSE_FIXED || encoding == ColumnEncoding.SPARSE_FIXED;
+ }
+
+ /**
+ * The description of this column for the manifest.
+ */
+ public Manifest.ColumnInfo info() {
+ return info;
+ }
+
+ /**
+ * The number of values appended so far.
+ */
+ public long appendedCount() {
+ return appended;
+ }
+
+ /**
+ * Appends a value, encoding it with {@link ValueCodec}. The value's type must be one of {@code info.valueTypes()}.
+ * A null value is appended as by {@link #appendNull}.
+ *
+ * @param ordinal the element ordinal, greater than the previous one and less than the element count
+ * @throws UnsupportedSnapshotDataException if the value is of an unsupported class
+ */
+ public void append(final long ordinal, final Object value) {
+ if (value == null) {
+ appendNull(ordinal);
+ return;
+ }
+ final ValueType type = ValueCodec.requireType(value, "value of column " + relativeDir, null);
+ begin(ordinal, type);
+ if (fixed) {
+ writeFixed(ValueCodec.fixedBits(type, value));
+ } else {
+ final byte[] encoded = ValueCodec.encode(type, value);
+ writeVariable(type, encoded, 0, encoded.length);
+ }
+ appended++;
+ }
+
+ /**
+ * Appends a null value: a present entry with a zero value or an empty range, marked in {@code nulls.bin}. It
+ * records no value type, so it needs no entry in {@code info.valueTypes()}, and it counts towards the present count
+ * and the null count of the column's info.
+ *
+ * @param ordinal the element ordinal, greater than the previous one and less than the element count
+ * @throws IllegalStateException if the info's null count is already reached
+ */
+ public void appendNull(final long ordinal) {
+ if (nulled >= info.nullCount()) {
+ throw new IllegalStateException("Column " + relativeDir + " exceeds its null count " + info.nullCount());
+ }
+ final long entry = dense ? ordinal : appended;
+ begin(ordinal, null);
+ writeAbsent();
+ markNull(entry);
+ nulled++;
+ appended++;
+ }
+
+ private void markNull(final long entry) {
+ final long word = entry >>> 6;
+ while (nullWordsWritten < word) {
+ nulls.writeLong(nullWord);
+ nullWord = 0;
+ nullWordsWritten++;
+ }
+ nullWord |= 1L << (entry & 63);
+ }
+
+ /**
+ * Appends an already encoded value, for example one replayed from a spool. For a fixed-width type the payload must
+ * be exactly its width in little-endian order, as produced by {@link ValueCodec#encode(ValueType, Object)}.
+ *
+ * @param ordinal the element ordinal, greater than the previous one and less than the element count
+ * @param type the value's type, one of {@code info.valueTypes()}
+ */
+ public void appendEncoded(final long ordinal, final ValueType type, final byte[] payload, final int offset,
+ final int length) {
+ begin(ordinal, type);
+ if (fixed) {
+ writeFixed(ValueCodec.fixedBitsFromBytes(type, payload, offset, length));
+ } else {
+ writeVariable(type, payload, offset, length);
+ }
+ appended++;
+ }
+
+ // a null type is a null value
+ private void begin(final long ordinal, final ValueType type) {
+ if (finished) throw new IllegalStateException("Column " + relativeDir + " is already finished");
+ if (ordinal <= lastOrdinal || ordinal >= elementCount) {
+ throw new IllegalArgumentException("Ordinal " + ordinal + " of column " + relativeDir + " is not in ("
+ + lastOrdinal + ", " + elementCount + ")");
+ }
+ if (type != null && !allowed[type.code() & 0xff]) {
+ throw new IllegalArgumentException("Type " + type + " is not among the types " + info.valueTypes()
+ + " of column " + relativeDir);
+ }
+ if (appended >= info.presentCount()) {
+ throw new IllegalStateException("Column " + relativeDir + " exceeds its present count "
+ + info.presentCount());
+ }
+ lastOrdinal = ordinal;
+ if (dense) {
+ while (nextPosition < ordinal) {
+ writeAbsent();
+ nextPosition++;
+ }
+ nextPosition = ordinal + 1;
+ if (presence != null) markPresent(ordinal);
+ } else if (ordinals != null) {
+ ordinals.writeInt((int) ordinal);
+ }
+ }
+
+ private void markPresent(final long ordinal) {
+ final long word = ordinal >>> 6;
+ while (presenceWordsWritten < word) {
+ presence.writeLong(presenceWord);
+ presenceWord = 0;
+ presenceWordsWritten++;
+ }
+ presenceWord |= 1L << (ordinal & 63);
+ }
+
+ private void writeFixed(final long bits) {
+ switch (fixedWidth) {
+ case 1:
+ values.writeByte((int) bits);
+ break;
+ case 2:
+ values.writeShort((int) bits);
+ break;
+ case 4:
+ values.writeInt((int) bits);
+ break;
+ default:
+ values.writeLong(bits);
+ break;
+ }
+ }
+
+ private void writeVariable(final ValueType type, final byte[] payload, final int offset, final int length) {
+ if (type.isFixedWidth() && length != type.width()) {
+ throw new IllegalArgumentException("Type " + type + " requires " + type.width() + " bytes, got " + length);
+ }
+ offsets.writeLong(dataLength);
+ data.writeBytes(payload, offset, length);
+ dataLength += length;
+ if (mixed) types.writeByte(type.code());
+ }
+
+ // an absent position of a dense column
+ private void writeAbsent() {
+ if (fixed) {
+ switch (fixedWidth) {
+ case 1:
+ values.writeByte(0);
+ break;
+ case 2:
+ values.writeShort(0);
+ break;
+ case 4:
+ values.writeInt(0);
+ break;
+ default:
+ values.writeLong(0);
+ break;
+ }
+ } else {
+ offsets.writeLong(dataLength);
+ if (mixed) types.writeByte(0);
+ }
+ }
+
+ /**
+ * Completes the column: writes the remaining absent positions, the presence bitmap and the final offset, and
+ * finalizes every segment header. Idempotent.
+ *
+ * @return the segments written, in ascending path order
+ * @throws IllegalStateException if the number of appended values differs from the present count
+ */
+ public List<Manifest.SegmentInfo> finish() {
+ if (finished) return segments;
+ if (appended != info.presentCount()) {
+ throw new IllegalStateException("Column " + relativeDir + " received " + appended + " values, expected "
+ + info.presentCount());
+ }
+ if (dense) {
+ while (nextPosition < elementCount) {
+ writeAbsent();
+ nextPosition++;
+ }
+ }
+ if (presence != null) {
+ final long totalWords = (elementCount + 63) >>> 6;
+ while (presenceWordsWritten < totalWords) {
+ presence.writeLong(presenceWord);
+ presenceWord = 0;
+ presenceWordsWritten++;
+ }
+ }
+ if (!fixed) offsets.writeLong(dataLength);
+ if (nulled != info.nullCount()) {
+ throw new IllegalStateException("Column " + relativeDir + " received " + nulled + " nulls, expected "
+ + info.nullCount());
+ }
+ if (nulls != null) {
+ final long totalWords = ((dense ? elementCount : info.presentCount()) + 63) >>> 6;
+ while (nullWordsWritten < totalWords) {
+ nulls.writeLong(nullWord);
+ nullWord = 0;
+ nullWordsWritten++;
+ }
+ }
+ finished = true;
+ add(presence, SegmentPaths.PRESENCE);
+ add(ordinals, SegmentPaths.ORDINALS);
+ add(values, SegmentPaths.VALUES);
+ add(offsets, SegmentPaths.OFFSETS);
+ add(data, SegmentPaths.DATA);
+ add(types, SegmentPaths.TYPES);
+ add(nulls, SegmentPaths.NULLS);
+ segments.sort(Comparator.comparing(Manifest.SegmentInfo::path));
+ return segments;
+ }
+
+ private void add(final SegmentWriter writer, final String fileName) {
+ if (writer == null) return;
+ writer.finish();
+ segments.add(writer.info(SegmentPaths.file(relativeDir, fileName)));
+ }
+
+ /**
+ * The segments of a finished column, see {@link #finish()}.
+ */
+ public List<Manifest.SegmentInfo> segments() {
+ if (!finished) throw new IllegalStateException("Column " + relativeDir + " is not finished");
+ return segments;
+ }
+
+ /**
+ * Releases the files of a column that was not finished, for example after a failure. The partial files are left
+ * for the build directory cleanup. Does nothing after {@link #finish()}.
+ */
+ @Override
+ public void close() {
+ if (!finished) closeQuietly();
+ }
+
+ private void closeQuietly() {
+ for (final SegmentWriter w : new SegmentWriter[]{presence, ordinals, values, offsets, data, types, nulls}) {
+ if (w == null) continue;
+ try {
+ w.close();
+ } catch (RuntimeException ignored) {
+ // the build is failing and its directory will be deleted
+ }
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/IdentifierIndex.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/IdentifierIndex.java
new file mode 100644
index 0000000000..2ba010e053
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/IdentifierIndex.java
@@ -0,0 +1,299 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.VarHandle;
+import java.nio.ByteOrder;
+import java.util.Objects;
+
+/**
+ * The identifier-to-ordinal index of vertices or edges, and the key function and ordering shared by everything that
+ * builds one. The index is two parallel segments, {@code id-index-keys.bin} (int64) and {@code id-index-ordinals.bin}
+ * (int32), with entries sorted by key and then by ordinal, both compared as signed numbers.
+ * <p/>
+ * The key of an identifier is:
+ * <ul>
+ * <li>its numeric value for the types {@code BYTE}, {@code SHORT}, {@code INT} and {@code LONG};</li>
+ * <li>otherwise a fixed 64-bit hash of the type code and the encoded bytes, see
+ * {@link #keyOf(ValueType, byte[], int, int)}.</li>
+ * </ul>
+ * The index is <em>exact</em> when the identifier column holds a single integral type: a key then identifies the
+ * identifier completely and lookup needs no column access. Otherwise entries with an equal key are checked against the
+ * identifier column, comparing type and encoded bytes, so a hash collision or an {@code Integer 1} versus
+ * {@code Long 1} is never confused. Identifier equality is thus type plus encoded bytes.
+ * <p/>
+ * An index is built with {@link IdentifierIndexWriter} and looked up with {@link #lookup(Object)}. Lookups may be
+ * made from several threads.
+ */
+public final class IdentifierIndex {
+
+ private static final VarHandle SHORT = MethodHandles.byteArrayViewVarHandle(short[].class, ByteOrder.LITTLE_ENDIAN);
+ private static final VarHandle INT = MethodHandles.byteArrayViewVarHandle(int[].class, ByteOrder.LITTLE_ENDIAN);
+ private static final VarHandle LONG = MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN);
+
+ private static final long FNV_OFFSET = 0xcbf29ce484222325L;
+ private static final long FNV_PRIME = 0x100000001b3L;
+
+ private final MappedSegment keys;
+ private final MappedSegment ordinals;
+ private final ValueType exactType;
+ private final ColumnReader ids;
+ private final long count;
+
+ private IdentifierIndex(final MappedSegment keys, final MappedSegment ordinals, final ValueType exactType,
+ final ColumnReader ids) {
+ this.keys = keys;
+ this.ordinals = ordinals;
+ this.exactType = exactType;
+ this.ids = ids;
+ this.count = keys.count();
+ }
+
+ // ---------------------------------------------------------------- keys and ordering
+
+ /**
+ * The single integral type that makes an index for the given identifier column exact, or null if the index is not
+ * exact: the column must hold exactly one type and that type must be {@code BYTE}, {@code SHORT}, {@code INT} or
+ * {@code LONG}. The manifest's exact-index flag is {@code exactType(info) != null}.
+ */
+ public static ValueType exactType(final Manifest.ColumnInfo idInfo) {
+ if (idInfo.valueTypes().size() != 1) return null;
+ final ValueType t = idInfo.valueTypes().get(0);
+ return ValueCodec.isIntegral(t) ? t : null;
+ }
+
+ /**
+ * The index key of an encoded identifier.
+ *
+ * @param type the identifier's type
+ * @param encoded the buffer holding the identifier's canonical encoding
+ */
+ public static long keyOf(final ValueType type, final byte[] encoded, final int offset, final int length) {
+ switch (type) {
+ case BYTE:
+ return encoded[offset];
+ case SHORT:
+ return (short) SHORT.get(encoded, offset);
+ case INT:
+ return (int) INT.get(encoded, offset);
+ case LONG:
+ return (long) LONG.get(encoded, offset);
+ default:
+ return hash(type, encoded, offset, length);
+ }
+ }
+
+ /**
+ * The index key of an identifier object.
+ *
+ * @throws org.apache.tinkerpop.gremlin.structure.snapshot.spi.UnsupportedSnapshotDataException if the identifier
+ * is null or of an unsupported class
+ */
+ public static long keyOf(final Object id) {
+ final ValueType type = ValueCodec.requireIdentifierType(id, "identifier", null);
+ return keyOf(type, id);
+ }
+
+ // the type must be the identifier's type
+ private static long keyOf(final ValueType type, final Object id) {
+ switch (type) {
+ case BYTE:
+ return (Byte) id;
+ case SHORT:
+ return (Short) id;
+ case INT:
+ return (Integer) id;
+ case LONG:
+ return (Long) id;
+ default: {
+ final byte[] encoded = ValueCodec.encode(type, id);
+ return hash(type, encoded, 0, encoded.length);
+ }
+ }
+ }
+
+ /**
+ * The fixed 64-bit hash used as the key of non-integral identifiers: FNV-1a over the type code and the encoded
+ * bytes, followed by the MurmurHash3 64-bit finalizer. Every builder and the reader must use this function.
+ */
+ public static long hash(final ValueType type, final byte[] encoded, final int offset, final int length) {
+ long h = FNV_OFFSET;
+ h ^= type.code() & 0xff;
+ h *= FNV_PRIME;
+ for (int i = offset; i < offset + length; i++) {
+ h ^= encoded[i] & 0xff;
+ h *= FNV_PRIME;
+ }
+ h ^= h >>> 33;
+ h *= 0xff51afd7ed558ccdL;
+ h ^= h >>> 33;
+ h *= 0xc4ceb9fe1a85ec53L;
+ h ^= h >>> 33;
+ return h;
+ }
+
+ /**
+ * The index order: by key, then by ordinal, both signed. Builders that sort entries themselves, for example an
+ * external sort of a spool, must use this order.
+ */
+ public static int compare(final long keyA, final int ordinalA, final long keyB, final int ordinalB) {
+ final int c = Long.compare(keyA, keyB);
+ return c != 0 ? c : Integer.compare(ordinalA, ordinalB);
+ }
+
+ /**
+ * Sorts the first {@code count} entries of the parallel arrays into index order, stably and in place. Intended for
+ * the materializing builder; it needs a temporary copy of both arrays.
+ */
+ public static void sortEntries(final long[] keys, final int[] ordinals, final int count) {
+ Objects.checkFromIndexSize(0, count, keys.length);
+ Objects.checkFromIndexSize(0, count, ordinals.length);
+ boolean sorted = true;
+ for (int i = 1; i < count && sorted; i++) {
+ sorted = compare(keys[i - 1], ordinals[i - 1], keys[i], ordinals[i]) <= 0;
+ }
+ if (sorted) return;
+ long[] srcK = keys;
+ int[] srcO = ordinals;
+ long[] dstK = new long[count];
+ int[] dstO = new int[count];
+ for (int width = 1; width < count; width <<= 1) {
+ for (int lo = 0; lo < count; lo += 2 * width) {
+ final int mid = Math.min(lo + width, count);
+ final int hi = Math.min(lo + 2 * width, count);
+ int a = lo;
+ int b = mid;
+ int out = lo;
+ while (a < mid && b < hi) {
+ if (compare(srcK[a], srcO[a], srcK[b], srcO[b]) <= 0) {
+ dstK[out] = srcK[a];
+ dstO[out++] = srcO[a++];
+ } else {
+ dstK[out] = srcK[b];
+ dstO[out++] = srcO[b++];
+ }
+ }
+ while (a < mid) {
+ dstK[out] = srcK[a];
+ dstO[out++] = srcO[a++];
+ }
+ while (b < hi) {
+ dstK[out] = srcK[b];
+ dstO[out++] = srcO[b++];
+ }
+ }
+ final long[] tk = srcK;
+ srcK = dstK;
+ dstK = tk;
+ final int[] to = srcO;
+ srcO = dstO;
+ dstO = to;
+ }
+ if (srcK != keys) {
+ System.arraycopy(srcK, 0, keys, 0, count);
+ System.arraycopy(srcO, 0, ordinals, 0, count);
+ }
+ }
+
+ // ---------------------------------------------------------------- lookup
+
+ /**
+ * Wraps the two mapped index segments for lookup.
+ *
+ * @param keys {@code id-index-keys.bin}, width 8
+ * @param ordinals {@code id-index-ordinals.bin}, width 4, with the same count
+ * @param exactType the result of {@link #exactType(Manifest.ColumnInfo)}, null for a non-exact index
+ * @param ids the identifier column, required when the index is not exact and otherwise optional
+ */
+ public static IdentifierIndex of(final MappedSegment keys, final MappedSegment ordinals, final ValueType exactType,
+ final ColumnReader ids) {
+ if (keys.valueWidth() != 8 || ordinals.valueWidth() != 4 || keys.count() != ordinals.count()) {
+ throw new IllegalStateException("Identifier index segments " + keys.path() + " and " + ordinals.path()
+ + " do not match: widths " + keys.valueWidth() + " and " + ordinals.valueWidth() + ", counts "
+ + keys.count() + " and " + ordinals.count());
+ }
+ if (exactType == null && ids == null) {
+ throw new IllegalArgumentException("A non-exact identifier index needs the identifier column");
+ }
+ return new IdentifierIndex(keys, ordinals, exactType, ids);
+ }
+
+ /**
+ * The number of entries, which is the number of elements.
+ */
+ public long count() {
+ return count;
+ }
+
+ public boolean isExact() {
+ return exactType != null;
+ }
+
+ /**
+ * The ordinal of the element with the given identifier, or -1 if there is none. An identifier of a type that cannot
+ * be stored, such as null, simply has no match. Identifiers of different types never match each other.
+ */
+ public int lookup(final Object id) {
+ final ValueType type = ValueCodec.typeOf(id);
+ if (type == null) return -1;
+ if (exactType != null) {
+ if (type != exactType) return -1;
+ // the type is integral, so the key is the value; the first entry is the only one
+ final long key = keyOf(type, id);
+ final long first = lowerBound(key);
+ return first < count && keys.getLong(first) == key ? ordinals.getInt(first) : -1;
+ }
+ final byte[] encoded = ValueCodec.encode(type, id);
+ final long key = keyOf(type, encoded, 0, encoded.length);
+ for (long i = lowerBound(key); i < count && keys.getLong(i) == key; i++) {
+ final int ordinal = ordinals.getInt(i);
+ if (ids.matches(ordinal, type, encoded)) return ordinal;
+ }
+ return -1;
+ }
+
+ /**
+ * The ordinal of the element whose integral identifier has the given numeric value, whatever its width, or -1 if
+ * there is none. Only an exact index can answer this, since there every key is the identifier's numeric value and
+ * every identifier is integral; no column access is needed.
+ *
+ * @throws IllegalStateException if the index is not exact
+ */
+ public int lookupIntegral(final long value) {
+ if (exactType == null) throw new IllegalStateException("Integral lookup needs an exact identifier index");
+ final long first = lowerBound(value);
+ return first < count && keys.getLong(first) == value ? ordinals.getInt(first) : -1;
+ }
+
+ // the first entry whose key is >= the given key, or count
+ private long lowerBound(final long key) {
+ long lo = 0;
+ long hi = count;
+ while (lo < hi) {
+ final long mid = (lo + hi) >>> 1;
+ if (keys.getLong(mid) < key) {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ return lo;
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/IdentifierIndexWriter.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/IdentifierIndexWriter.java
new file mode 100644
index 0000000000..359521dcc8
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/IdentifierIndexWriter.java
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.UnsupportedSnapshotDataException;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Writes {@code id-index-keys.bin} and {@code id-index-ordinals.bin} from entries presented in
+ * {@linkplain IdentifierIndex#compare index order}, that is sorted by key and then ordinal. The materializing builder
+ * sorts in heap and calls {@link #addAllSorting(long[], int)}; the streaming builder external-sorts its spool and calls
+ * {@link #add(long, int)} for every entry. The output is identical.
+ * <p/>
+ * The writer also enforces that identifiers are unique, failing the build with
+ * {@link UnsupportedSnapshotDataException} on a duplicate. For an exact index a repeated key is a duplicate. For a
+ * non-exact index a repeated key may merely be a hash collision, so the writer compares the colliding entries in the
+ * identifier column, which must therefore be complete and mapped before the index is written.
+ * <p/>
+ * {@link #finish()} checks that the number of entries equals the element count, so no element is left out.
+ * Instances are not thread-safe.
+ */
+public final class IdentifierIndexWriter implements AutoCloseable {
+
+ private final SegmentWriter keys;
+ private final SegmentWriter ordinals;
+ private final String elementKind;
+ private final long elementCount;
+ private final ValueType exactType;
+ private final ColumnReader ids;
+
+ private boolean any;
+ private long lastKey;
+ private int lastOrdinal;
+ // ordinals that share lastKey, for duplicate detection in a non-exact index
+ private int[] group = new int[4];
+ private int groupSize;
+ private boolean finished;
+
+ private IdentifierIndexWriter(final Path keysPath, final Path ordinalsPath, final String elementKind,
+ final long elementCount, final ValueType exactType, final ColumnReader ids) {
+ this.elementKind = elementKind;
+ this.elementCount = elementCount;
+ this.exactType = exactType;
+ this.ids = ids;
+ this.keys = SegmentWriter.create(keysPath, 8);
+ try {
+ this.ordinals = SegmentWriter.create(ordinalsPath, 4);
+ } catch (RuntimeException e) {
+ keys.close();
+ throw e;
+ }
+ }
+
+ /**
+ * Creates an index writer.
+ *
+ * @param keysPath where to write {@code id-index-keys.bin}
+ * @param ordinalsPath where to write {@code id-index-ordinals.bin}
+ * @param elementKind {@code "vertex"} or {@code "edge"}, for error messages
+ * @param elementCount the number of elements, which is the number of entries that must be added
+ * @param exactType the result of {@link IdentifierIndex#exactType(Manifest.ColumnInfo)} for the identifier
+ * column, null for a non-exact index
+ * @param ids the complete identifier column, required unless the index is exact
+ */
+ public static IdentifierIndexWriter create(final Path keysPath, final Path ordinalsPath, final String elementKind,
+ final long elementCount, final ValueType exactType,
+ final ColumnReader ids) {
+ if (exactType == null && ids == null) {
+ throw new IllegalArgumentException("A non-exact identifier index needs the identifier column");
+ }
+ return new IdentifierIndexWriter(keysPath, ordinalsPath, elementKind, elementCount, exactType, ids);
+ }
+
+ /**
+ * Whether the index being written is exact, which is the manifest's exact-index flag.
+ */
+ public boolean isExact() {
+ return exactType != null;
+ }
+
+ /**
+ * Appends the next entry.
+ *
+ * @throws IllegalStateException if the entry is not after the previous one in index order
+ * @throws UnsupportedSnapshotDataException if the identifier duplicates an earlier one
+ */
+ public void add(final long key, final int ordinal) {
+ if (any) {
+ final int order = IdentifierIndex.compare(lastKey, lastOrdinal, key, ordinal);
+ if (order >= 0) {
+ throw new IllegalStateException(elementKind + " identifier index entries out of order: (" + lastKey
+ + ", " + lastOrdinal + ") then (" + key + ", " + ordinal + ")");
+ }
+ if (key == lastKey) checkDuplicate(key, ordinal);
+ else groupSize = 0;
+ }
+ if (groupSize == group.length) group = java.util.Arrays.copyOf(group, groupSize * 2);
+ group[groupSize++] = ordinal;
+ keys.writeLong(key);
+ ordinals.writeInt(ordinal);
+ any = true;
+ lastKey = key;
+ lastOrdinal = ordinal;
+ }
+
+ private void checkDuplicate(final long key, final int ordinal) {
+ if (exactType != null) {
+ throw duplicate(key, lastOrdinal, ordinal, null);
+ }
+ for (int i = 0; i < groupSize; i++) {
+ if (ids.sameValue(group[i], ordinal)) {
+ throw duplicate(key, group[i], ordinal, ids.get(ordinal));
+ }
+ }
+ }
+
+ private UnsupportedSnapshotDataException duplicate(final long key, final int first, final int second,
+ final Object id) {
+ return new UnsupportedSnapshotDataException("Duplicate " + elementKind + " identifier "
+ + (id != null ? id + " (" + id.getClass().getSimpleName() + ")" : "with key " + key)
+ + " at ordinals " + first + " and " + second);
+ }
+
+ /**
+ * Sorts the keys of all elements and adds every entry, for a builder that holds the keys in heap. The caller's array
+ * is not modified.
+ *
+ * @param keysByOrdinal the key of each element, indexed by its ordinal
+ * @param count the number of elements
+ */
+ public void addAllSorting(final long[] keysByOrdinal, final int count) {
+ final long[] sortedKeys = java.util.Arrays.copyOf(keysByOrdinal, count);
+ final int[] sortedOrdinals = new int[count];
+ for (int i = 0; i < count; i++) sortedOrdinals[i] = i;
+ IdentifierIndex.sortEntries(sortedKeys, sortedOrdinals, count);
+ for (int i = 0; i < count; i++) add(sortedKeys[i], sortedOrdinals[i]);
+ }
+
+ /**
+ * The number of entries added so far.
+ */
+ public long count() {
+ return keys.count();
+ }
+
+ /**
+ * Completes both segments.
+ *
+ * @return the keys segment and then the ordinals segment, using the given bundle-relative paths
+ * @throws IllegalStateException if the number of entries differs from the element count
+ */
+ public List<Manifest.SegmentInfo> finish(final String keysRelativePath, final String ordinalsRelativePath) {
+ if (!finished) {
+ if (keys.count() != elementCount) {
+ throw new IllegalStateException(elementKind + " identifier index has " + keys.count()
+ + " entries for " + elementCount + " elements");
+ }
+ keys.finish();
+ ordinals.finish();
+ finished = true;
+ }
+ final List<Manifest.SegmentInfo> out = new ArrayList<>(2);
+ out.add(keys.info(keysRelativePath));
+ out.add(ordinals.info(ordinalsRelativePath));
+ return out;
+ }
+
+ /**
+ * Releases the files of an index that was not finished.
+ */
+ @Override
+ public void close() {
+ if (finished) return;
+ try {
+ keys.close();
+ } finally {
+ ordinals.close();
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/LabelCounts.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/LabelCounts.java
new file mode 100644
index 0000000000..680e947cc6
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/LabelCounts.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.util.Arrays;
+
+/**
+ * Accumulates the number of elements per label code during a scan, for the optional label count statistics of the
+ * {@link Manifest}. Both builders use it so they record identical counts. A label code counts once for each occurrence,
+ * so a multi-label vertex counts once for each of its labels. Instances are not thread-safe.
+ */
+public final class LabelCounts {
+
+ private long[] counts = new long[8];
+
+ /**
+ * Records one occurrence of the label with the given code.
+ */
+ public void increment(final int code) {
+ if (code >= counts.length) counts = Arrays.copyOf(counts, Math.max(code + 1, counts.length * 2));
+ counts[code]++;
+ }
+
+ /**
+ * The counts indexed by label code.
+ *
+ * @param labelCount the size of the label dictionary, which is the length of the result
+ */
+ public long[] toArray(final int labelCount) {
+ return Arrays.copyOf(counts, labelCount);
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/LabelDictionary.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/LabelDictionary.java
new file mode 100644
index 0000000000..137f4ad040
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/LabelDictionary.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.UnsupportedSnapshotDataException;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Assigns dense codes to strings in order of first appearance. It is used for the vertex and edge label dictionaries
+ * and, as separate instances, for the vertex and edge property-key dictionaries. The code of a string is its index in
+ * {@link #labels()}, which is exactly the list stored in the {@code Manifest}.
+ * <p/>
+ * A {@code labels.bin} segment stores one code per element in the narrowest unsigned width that fits the final
+ * dictionary: 1 byte for at most 256 labels, 2 bytes for at most 65,536 and 4 bytes otherwise. The width is only known
+ * when the scan is complete, so a builder either holds the codes in heap and writes them with
+ * {@link #createCodeWriter(Path, int)}, or spools them as int32 and converts the spool with
+ * {@link #narrow(Path, Path, String, int)}. Both produce identical bytes.
+ * <p/>
+ * Instances are not thread-safe.
+ */
+public final class LabelDictionary {
+
+ private final Map<String, Integer> codes = new HashMap<>();
+ private final List<String> labels = new ArrayList<>();
+
+ /**
+ * The code of the string, assigning the next free code if it has not been seen.
+ *
+ * @throws UnsupportedSnapshotDataException if the string is null
+ */
+ public int codeOf(final String label) {
+ final Integer code = codes.get(label);
+ if (code != null) return code;
+ if (label == null) throw new UnsupportedSnapshotDataException("Null label or property key is not supported");
+ final int assigned = labels.size();
+ codes.put(label, assigned);
+ labels.add(label);
+ return assigned;
+ }
+
+ /**
+ * The number of strings seen so far.
+ */
+ public int size() {
+ return labels.size();
+ }
+
+ public String label(final int code) {
+ return labels.get(code);
+ }
+
+ /**
+ * The strings in code order, as stored in the manifest. The view is not a copy and grows as codes are assigned.
+ */
+ public List<String> labels() {
+ return Collections.unmodifiableList(labels);
+ }
+
+ /**
+ * The width in bytes of the stored codes for the current size.
+ */
+ public int codeWidth() {
+ return codeWidth(labels.size());
+ }
+
+ /**
+ * The width in bytes of stored codes for a dictionary of {@code labelCount} labels: 1, 2 or 4.
+ */
+ public static int codeWidth(final long labelCount) {
+ if (labelCount <= 1L << 8) return 1;
+ if (labelCount <= 1L << 16) return 2;
+ return 4;
+ }
+
+ /**
+ * Creates a writer for a {@code labels.bin} segment sized for a dictionary of {@code labelCount} labels. Append
+ * codes with {@link #writeCode(SegmentWriter, int)}.
+ */
+ public static SegmentWriter createCodeWriter(final Path path, final int labelCount) {
+ return SegmentWriter.create(path, codeWidth(labelCount));
+ }
+
+ /**
+ * Appends a code to a writer created by {@link #createCodeWriter(Path, int)}.
+ */
+ public static void writeCode(final SegmentWriter writer, final int code) {
+ switch (writer.valueWidth()) {
+ case 1:
+ writer.writeByte(code);
+ break;
+ case 2:
+ writer.writeShort(code);
+ break;
+ default:
+ writer.writeInt(code);
+ break;
+ }
+ }
+
+ /**
+ * Reads the code at {@code index} of a mapped {@code labels.bin}.
+ */
+ public static int readCode(final MappedSegment segment, final long index) {
+ switch (segment.valueWidth()) {
+ case 1:
+ return segment.getByte(index) & 0xff;
+ case 2:
+ return segment.getShort(index) & 0xffff;
+ default:
+ return segment.getInt(index);
+ }
+ }
+
+ /**
+ * Reads the next code from a sequential reader of a {@code labels.bin}.
+ */
+ public static int readCode(final SegmentReader reader) {
+ switch (reader.valueWidth()) {
+ case 1:
+ return reader.readByte() & 0xff;
+ case 2:
+ return reader.readShort() & 0xffff;
+ default:
+ return reader.readInt();
+ }
+ }
+
+ /**
+ * Converts a spool of int32 codes into a {@code labels.bin} with the narrowest width for the final dictionary size,
+ * in one sequential pass.
+ *
+ * @param spool an int32 segment of label codes, one per element
+ * @param output where to write the narrowed segment
+ * @param relativePath the segment's bundle-relative path for the returned info
+ * @param labelCount the final number of labels, which decides the width
+ * @return the finished segment, ready for the manifest
+ * @throws IllegalStateException if the spool contains a code outside {@code [0, labelCount)}
+ */
+ public static Manifest.SegmentInfo narrow(final Path spool, final Path output, final String relativePath,
+ final int labelCount) {
+ try (SegmentReader in = SegmentReader.open(spool);
+ SegmentWriter out = createCodeWriter(output, labelCount)) {
+ final long n = in.count();
+ for (long i = 0; i < n; i++) {
+ final int code = in.readInt();
+ if (code < 0 || code >= labelCount) {
+ throw new IllegalStateException("Label code " + code + " at index " + i + " of " + spool
+ + " is outside the dictionary of " + labelCount + " labels");
+ }
+ writeCode(out, code);
+ }
+ out.finish();
+ return out.info(relativePath);
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/LabelListWriter.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/LabelListWriter.java
new file mode 100644
index 0000000000..3a4aac5dcf
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/LabelListWriter.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Writes the multi vertex label layout: {@code vertices/label-offsets.bin}, an int64 segment with one more entry than
+ * there are vertices, and {@code vertices/label-codes.bin}, the label codes of all vertices in vertex order, each
+ * vertex's codes in source order, narrowed to the width that fits the final dictionary (see
+ * {@link LabelDictionary#codeWidth(long)}). A vertex with no labels has an empty range. Both builders use this class,
+ * the materializing builder from its heap lists and the streaming builder by replaying its label spool, so the bytes
+ * are identical.
+ * <p/>
+ * For each vertex in ascending ordinal order, call {@link #add(int)} once per label and then {@link #endVertex()}. The
+ * final dictionary size must be known when the writer is created. Instances are not thread-safe.
+ */
+public final class LabelListWriter implements AutoCloseable {
+
+ private final SegmentWriter offsets;
+ private final SegmentWriter codes;
+ private final int labelCount;
+ private long vertices;
+ private long total;
+ private List<Manifest.SegmentInfo> segments;
+
+ private LabelListWriter(final SegmentWriter offsets, final SegmentWriter codes, final int labelCount) {
+ this.offsets = offsets;
+ this.codes = codes;
+ this.labelCount = labelCount;
+ offsets.writeLong(0);
+ }
+
+ /**
+ * Creates the writer.
+ *
+ * @param resolver maps a path relative to the bundle root (or the scratch root) to a file, for example
+ * {@code BuildDirectory::segmentPath}
+ * @param labelCount the final number of vertex labels, which decides the code width
+ */
+ public static LabelListWriter create(final Function<String, Path> resolver, final int labelCount) {
+ final SegmentWriter offsets = SegmentWriter.create(resolver.apply(SegmentPaths.VERTEX_LABEL_OFFSETS), 8);
+ try {
+ final SegmentWriter codes = LabelDictionary.createCodeWriter(
+ resolver.apply(SegmentPaths.VERTEX_LABEL_CODES), labelCount);
+ return new LabelListWriter(offsets, codes, labelCount);
+ } catch (RuntimeException e) {
+ offsets.close();
+ throw e;
+ }
+ }
+
+ /**
+ * Appends a label code of the current vertex.
+ *
+ * @throws IllegalArgumentException if the code is outside the dictionary
+ */
+ public void add(final int code) {
+ if (code < 0 || code >= labelCount) {
+ throw new IllegalArgumentException("Label code " + code + " is outside the dictionary of " + labelCount
+ + " labels");
+ }
+ LabelDictionary.writeCode(codes, code);
+ total++;
+ }
+
+ /**
+ * Ends the current vertex, which may have had no labels, and starts the next.
+ */
+ public void endVertex() {
+ offsets.writeLong(total);
+ vertices++;
+ }
+
+ /**
+ * Completes both segments.
+ *
+ * @param vertexCount the number of vertices, checked against the number of {@link #endVertex()} calls
+ * @return the two segments, in ascending path order
+ */
+ public List<Manifest.SegmentInfo> finish(final long vertexCount) {
+ if (segments != null) return segments;
+ if (vertices != vertexCount) {
+ throw new IllegalStateException("Label lists were written for " + vertices + " vertices, expected "
+ + vertexCount);
+ }
+ offsets.finish();
+ codes.finish();
+ final List<Manifest.SegmentInfo> out = new ArrayList<>();
+ out.add(offsets.info(SegmentPaths.VERTEX_LABEL_OFFSETS));
+ out.add(codes.info(SegmentPaths.VERTEX_LABEL_CODES));
+ out.sort(Comparator.comparing(Manifest.SegmentInfo::path));
+ segments = out;
+ return segments;
+ }
+
+ @Override
+ public void close() {
+ offsets.close();
+ codes.close();
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/Manifest.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/Manifest.java
new file mode 100644
index 0000000000..772010672e
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/Manifest.java
@@ -0,0 +1,500 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.SourceDefaults;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * The model of {@code manifest.json}. This is a plain mutable data holder so builders can fill it in as they go and
+ * the JSON codec can populate it. It deliberately holds no timestamps, host names or builder names, so the same input
+ * always produces the same manifest.
+ * <p/>
+ * Dictionaries are lists in which the index is the code: a label code indexes {@code vertexLabels} or
+ * {@code edgeLabels} and a property-key code indexes {@code vertexKeys} or {@code edgeKeys}. Meta-property key codes
+ * index {@code metaKeys} and variable ordinals index {@code variableKeys}. The per-key column lists, including
+ * {@code vertexKeyInfos}, are parallel to the key lists and are empty unless the layout is
+ * {@link SnapshotLayout#FULL}.
+ * <p/>
+ * Builders assign label, key, meta-key and variable-key codes in order of first appearance: vertex scan order, then
+ * vertex-property order within a vertex, then meta-property order within a vertex property, and variables in
+ * {@code scanVariables} order. A vertex key uses the {@link PropertyLayout#MULTI} layout if and only if some vertex has
+ * more than one vertex property for it, and the vertex label layout is {@link VertexLabelLayout#MULTI} if and only if
+ * some vertex has a label count other than one.
+ */
+public final class Manifest {
+
+ /**
+ * The internal format version written to manifests and segment headers.
+ */
+ public static final int FORMAT_VERSION = 1;
+
+ /**
+ * Encoding, value types and present count of one column.
+ *
+ * @param encoding the file layout of the column
+ * @param valueTypes the observed value types, in ascending {@link ValueType#code()} order; more than one means
+ * the column is mixed-type and has a {@code types.bin} segment
+ * @param presentCount the number of elements with an entry, including null entries
+ * @param nullCount the number of present entries that are null; when greater than zero the column has a
+ * {@code nulls.bin}. Null entries contribute no value type.
+ * @param minValue optional statistic: the smallest non-null value of a column whose value types are all
+ * integral, or null when unknown
+ * @param maxValue optional statistic: the largest non-null value, see {@code minValue}
+ */
+ public record ColumnInfo(ColumnEncoding encoding, List<ValueType> valueTypes, long presentCount, long nullCount,
+ Long minValue, Long maxValue) {
+
+ /**
+ * A column without a known integral range.
+ */
+ public ColumnInfo(final ColumnEncoding encoding, final List<ValueType> valueTypes, final long presentCount,
+ final long nullCount) {
+ this(encoding, valueTypes, presentCount, nullCount, null, null);
+ }
+
+ /**
+ * A column without null values.
+ */
+ public ColumnInfo(final ColumnEncoding encoding, final List<ValueType> valueTypes, final long presentCount) {
+ this(encoding, valueTypes, presentCount, 0);
+ }
+
+ /**
+ * Whether the column has a recorded integral range.
+ */
+ public boolean hasRange() {
+ return minValue != null && maxValue != null;
+ }
+
+ public boolean isMixed() {
+ return valueTypes.size() > 1;
+ }
+ }
+
+ /**
+ * How vertex labels are stored: {@code SINGLE} is one code per vertex in {@code labels.bin}, {@code MULTI} is
+ * {@code label-offsets.bin} and {@code label-codes.bin}.
+ */
+ public enum VertexLabelLayout {
+ SINGLE, MULTI
+ }
+
+ /**
+ * How the vertex properties of one key are stored: {@code SINGLE} if no vertex has more than one, {@code MULTI}
+ * otherwise.
+ */
+ public enum PropertyLayout {
+ SINGLE, MULTI
+ }
+
+ /**
+ * The encoding of the owner files of a {@link PropertyLayout#MULTI} key: {@code DENSE} has only
+ * {@code owner-offsets.bin} over all vertices, {@code SPARSE} also has {@code owner-ordinals.bin}.
+ */
+ public enum OwnerEncoding {
+ DENSE, SPARSE
+ }
+
+ /**
+ * Layout of the vertex properties of one key.
+ *
+ * @param layout single or multi
+ * @param ownerEncoding dense or sparse owners, null for the single layout
+ * @param propertyCount the number of vertex properties of the key, which is the element count of its value,
+ * identifier and meta columns in the multi layout. In the single layout it equals the present
+ * count of the value column.
+ * @param metaColumns the meta-property columns by meta key code, in ascending code order. The element space of
+ * each is the key's vertex-property ordinals, which in the single layout are the column entry
+ * indexes. Empty when no vertex property of the key has meta-properties.
+ */
+ public record VertexKeyInfo(PropertyLayout layout, OwnerEncoding ownerEncoding, long propertyCount,
+ Map<Integer, ColumnInfo> metaColumns) {
+
+ public VertexKeyInfo {
+ metaColumns = new TreeMap<>(metaColumns);
+ }
+ }
+
+ /**
+ * Metadata of one published segment.
+ *
+ * @param path the path relative to the bundle root, using {@code '/'} separators
+ * @param width the value width in bytes
+ * @param count the number of values
+ * @param checksum the CRC32C of the payload, in the low 32 bits
+ */
+ public record SegmentInfo(String path, int width, long count, long checksum) {
+ }
+
+ private int formatVersion = FORMAT_VERSION;
+ private String sourceId;
+ private String sourceVersion;
+ private SnapshotLayout layout;
+ private long vertexCount;
+ private long edgeCount;
+ private int vertexOrdinalWidth = Integer.BYTES;
+ private int edgeOrdinalWidth = Integer.BYTES;
+ private List<String> vertexLabels = new ArrayList<>();
+ private List<String> edgeLabels = new ArrayList<>();
+ private List<String> vertexKeys = new ArrayList<>();
+ private List<String> edgeKeys = new ArrayList<>();
+ private VertexLabelLayout vertexLabelLayout = VertexLabelLayout.SINGLE;
+ private SourceDefaults sourceDefaults = SourceDefaults.DEFAULT;
+ private List<String> metaKeys = new ArrayList<>();
+ private List<String> variableKeys = new ArrayList<>();
+ private List<VertexKeyInfo> vertexKeyInfos = new ArrayList<>();
+ private ColumnInfo variables;
+ private ColumnInfo vertexIds;
+ private ColumnInfo edgeIds;
+ private List<ColumnInfo> vertexProperties = new ArrayList<>();
+ private List<ColumnInfo> vertexPropertyIds = new ArrayList<>();
+ private List<ColumnInfo> edgeProperties = new ArrayList<>();
+ private long[] vertexLabelCounts;
+ private long[] edgeLabelCounts;
+ private boolean outEdgesImplicit;
+ private boolean edgeIdIndex;
+ private boolean vertexIdIndexExact;
+ private boolean edgeIdIndexExact;
+ private List<SegmentInfo> segments = new ArrayList<>();
+
+ public int getFormatVersion() {
+ return formatVersion;
+ }
+
+ public void setFormatVersion(final int formatVersion) {
+ this.formatVersion = formatVersion;
+ }
+
+ /**
+ * {@code sourceId} of the {@code SourceVersion} the snapshot was built from.
+ */
+ public String getSourceId() {
+ return sourceId;
+ }
+
+ public void setSourceId(final String sourceId) {
+ this.sourceId = sourceId;
+ }
+
+ /**
+ * {@code version} of the {@code SourceVersion} the snapshot was built from.
+ */
+ public String getSourceVersion() {
+ return sourceVersion;
+ }
+
+ public void setSourceVersion(final String sourceVersion) {
+ this.sourceVersion = sourceVersion;
+ }
+
+ public SnapshotLayout getLayout() {
+ return layout;
+ }
+
+ public void setLayout(final SnapshotLayout layout) {
+ this.layout = layout;
+ }
+
+ public long getVertexCount() {
+ return vertexCount;
+ }
+
+ public void setVertexCount(final long vertexCount) {
+ this.vertexCount = vertexCount;
+ }
+
+ public long getEdgeCount() {
+ return edgeCount;
+ }
+
+ public void setEdgeCount(final long edgeCount) {
+ this.edgeCount = edgeCount;
+ }
+
+ /**
+ * Width in bytes of vertex ordinals, currently always 4.
+ */
+ public int getVertexOrdinalWidth() {
+ return vertexOrdinalWidth;
+ }
+
+ public void setVertexOrdinalWidth(final int vertexOrdinalWidth) {
+ this.vertexOrdinalWidth = vertexOrdinalWidth;
+ }
+
+ /**
+ * Width in bytes of edge ordinals, currently always 4.
+ */
+ public int getEdgeOrdinalWidth() {
+ return edgeOrdinalWidth;
+ }
+
+ public void setEdgeOrdinalWidth(final int edgeOrdinalWidth) {
+ this.edgeOrdinalWidth = edgeOrdinalWidth;
+ }
+
+ /**
+ * Vertex label dictionary, in order of first appearance. Empty for {@link SnapshotLayout#TOPOLOGY}.
+ */
+ public List<String> getVertexLabels() {
+ return vertexLabels;
+ }
+
+ public void setVertexLabels(final List<String> vertexLabels) {
+ this.vertexLabels = vertexLabels;
+ }
+
+ public List<String> getEdgeLabels() {
+ return edgeLabels;
+ }
+
+ public void setEdgeLabels(final List<String> edgeLabels) {
+ this.edgeLabels = edgeLabels;
+ }
+
+ /**
+ * Vertex property-key dictionary, in order of first appearance. Empty unless the layout is
+ * {@link SnapshotLayout#FULL}.
+ */
+ public List<String> getVertexKeys() {
+ return vertexKeys;
+ }
+
+ public void setVertexKeys(final List<String> vertexKeys) {
+ this.vertexKeys = vertexKeys;
+ }
+
+ public List<String> getEdgeKeys() {
+ return edgeKeys;
+ }
+
+ public void setEdgeKeys(final List<String> edgeKeys) {
+ this.edgeKeys = edgeKeys;
+ }
+
+ /**
+ * How vertex labels are stored. Meaningful only when the layout publishes labels, that is not
+ * {@link SnapshotLayout#TOPOLOGY}.
+ */
+ public VertexLabelLayout getVertexLabelLayout() {
+ return vertexLabelLayout;
+ }
+
+ public void setVertexLabelLayout(final VertexLabelLayout vertexLabelLayout) {
+ this.vertexLabelLayout = vertexLabelLayout;
+ }
+
+ /**
+ * The source's default labels and cardinalities, see {@link SourceDefaults}.
+ */
+ public SourceDefaults getSourceDefaults() {
+ return sourceDefaults;
+ }
+
+ public void setSourceDefaults(final SourceDefaults sourceDefaults) {
+ this.sourceDefaults = sourceDefaults;
+ }
+
+ /**
+ * Meta-property key dictionary, in order of first appearance. Empty unless the layout is
+ * {@link SnapshotLayout#FULL}.
+ */
+ public List<String> getMetaKeys() {
+ return metaKeys;
+ }
+
+ public void setMetaKeys(final List<String> metaKeys) {
+ this.metaKeys = metaKeys;
+ }
+
+ /**
+ * Graph variable key dictionary, in {@code scanVariables} order; a key's code is its variable ordinal. Empty
+ * unless the layout is {@link SnapshotLayout#FULL}.
+ */
+ public List<String> getVariableKeys() {
+ return variableKeys;
+ }
+
+ public void setVariableKeys(final List<String> variableKeys) {
+ this.variableKeys = variableKeys;
+ }
+
+ /**
+ * The layout of each vertex key's vertex properties, parallel to {@link #getVertexKeys()}.
+ */
+ public List<VertexKeyInfo> getVertexKeyInfos() {
+ return vertexKeyInfos;
+ }
+
+ public void setVertexKeyInfos(final List<VertexKeyInfo> vertexKeyInfos) {
+ this.vertexKeyInfos = vertexKeyInfos;
+ }
+
+ /**
+ * The graph variables column, whose element space is the variable ordinals, or null when the source has no
+ * variables or the layout is not {@link SnapshotLayout#FULL}.
+ */
+ public ColumnInfo getVariables() {
+ return variables;
+ }
+
+ public void setVariables(final ColumnInfo variables) {
+ this.variables = variables;
+ }
+
+ /**
+ * The vertex identifier column, or null for {@link SnapshotLayout#TOPOLOGY}.
+ */
+ public ColumnInfo getVertexIds() {
+ return vertexIds;
+ }
+
+ public void setVertexIds(final ColumnInfo vertexIds) {
+ this.vertexIds = vertexIds;
+ }
+
+ /**
+ * The edge identifier column, or null for {@link SnapshotLayout#TOPOLOGY}.
+ */
+ public ColumnInfo getEdgeIds() {
+ return edgeIds;
+ }
+
+ public void setEdgeIds(final ColumnInfo edgeIds) {
+ this.edgeIds = edgeIds;
+ }
+
+ /**
+ * Vertex property value columns, parallel to {@link #getVertexKeys()}. The element space of a column is the vertex
+ * ordinals in the single layout and the key's vertex-property ordinals in the multi layout.
+ */
+ public List<ColumnInfo> getVertexProperties() {
+ return vertexProperties;
+ }
+
+ public void setVertexProperties(final List<ColumnInfo> vertexProperties) {
+ this.vertexProperties = vertexProperties;
+ }
+
+ /**
+ * Vertex-property identifier columns, parallel to {@link #getVertexKeys()}.
+ */
+ public List<ColumnInfo> getVertexPropertyIds() {
+ return vertexPropertyIds;
+ }
+
+ public void setVertexPropertyIds(final List<ColumnInfo> vertexPropertyIds) {
+ this.vertexPropertyIds = vertexPropertyIds;
+ }
+
+ /**
+ * Edge property value columns, parallel to {@link #getEdgeKeys()}.
+ */
+ public List<ColumnInfo> getEdgeProperties() {
+ return edgeProperties;
+ }
+
+ public void setEdgeProperties(final List<ColumnInfo> edgeProperties) {
+ this.edgeProperties = edgeProperties;
+ }
+
+ /**
+ * Optional statistic: the number of vertices with each vertex label, indexed by label code; a multi-label vertex
+ * counts once for each of its labels. Null when absent, in which case the reader computes it at open.
+ */
+ public long[] getVertexLabelCounts() {
+ return vertexLabelCounts;
+ }
+
+ public void setVertexLabelCounts(final long[] vertexLabelCounts) {
+ this.vertexLabelCounts = vertexLabelCounts;
+ }
+
+ /**
+ * Optional statistic: the number of edges with each edge label, indexed by label code. Null when absent.
+ */
+ public long[] getEdgeLabelCounts() {
+ return edgeLabelCounts;
+ }
+
+ public void setEdgeLabelCounts(final long[] edgeLabelCounts) {
+ this.edgeLabelCounts = edgeLabelCounts;
+ }
+
+ /**
+ * True when {@code out-edges.bin} is omitted because each edge's out-adjacency position equals its edge ordinal.
+ */
+ public boolean isOutEdgesImplicit() {
+ return outEdgesImplicit;
+ }
+
+ public void setOutEdgesImplicit(final boolean outEdgesImplicit) {
+ this.outEdgesImplicit = outEdgesImplicit;
+ }
+
+ /**
+ * True when the edge identifier index is published.
+ */
+ public boolean isEdgeIdIndex() {
+ return edgeIdIndex;
+ }
+
+ public void setEdgeIdIndex(final boolean edgeIdIndex) {
+ this.edgeIdIndex = edgeIdIndex;
+ }
+
+ /**
+ * True when the vertex identifier column holds a single integral type, so index keys are the identifiers themselves.
+ */
+ public boolean isVertexIdIndexExact() {
+ return vertexIdIndexExact;
+ }
+
+ public void setVertexIdIndexExact(final boolean vertexIdIndexExact) {
+ this.vertexIdIndexExact = vertexIdIndexExact;
+ }
+
+ /**
+ * True when the edge identifier column holds a single integral type. Meaningful only when the edge identifier
+ * index is published.
+ */
+ public boolean isEdgeIdIndexExact() {
+ return edgeIdIndexExact;
+ }
+
+ public void setEdgeIdIndexExact(final boolean edgeIdIndexExact) {
+ this.edgeIdIndexExact = edgeIdIndexExact;
+ }
+
+ /**
+ * Every published segment, in ascending path order.
+ */
+ public List<SegmentInfo> getSegments() {
+ return segments;
+ }
+
+ public void setSegments(final List<SegmentInfo> segments) {
+ this.segments = segments;
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ManifestIO.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ManifestIO.java
new file mode 100644
index 0000000000..d028701f57
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ManifestIO.java
@@ -0,0 +1,351 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.SourceDefaults;
+import org.apache.tinkerpop.shaded.jackson.core.JsonFactory;
+import org.apache.tinkerpop.shaded.jackson.core.JsonGenerator;
+import org.apache.tinkerpop.shaded.jackson.core.util.DefaultIndenter;
+import org.apache.tinkerpop.shaded.jackson.core.util.DefaultPrettyPrinter;
+import org.apache.tinkerpop.shaded.jackson.databind.JsonNode;
+import org.apache.tinkerpop.shaded.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Reads and writes {@code manifest.json}. The output is deterministic: fields are written in a fixed order, the
+ * segment list is sorted by path, indentation uses two spaces and {@code '\n'} regardless of platform, and nothing
+ * depends on time, host or builder. The same {@link Manifest} content therefore always yields the same bytes.
+ * <p/>
+ * The JSON is written by hand rather than by bean reflection so the field order and the handling of the
+ * {@link Manifest} records are fixed by this class alone. I/O and parse failures are reported as
+ * {@link UncheckedIOException}.
+ */
+public final class ManifestIO {
+
+ private static final JsonFactory FACTORY = new JsonFactory();
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private ManifestIO() {
+ }
+
+ /**
+ * Writes {@code manifest} to {@code path} as UTF-8, replacing an existing file.
+ */
+ public static void write(final Manifest manifest, final Path path) {
+ try {
+ Files.write(path, toJson(manifest).getBytes(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Reads a manifest file written by {@link #write}.
+ *
+ * @throws UncheckedIOException if the file cannot be read or does not hold a valid manifest
+ */
+ public static Manifest read(final Path path) {
+ try {
+ return fromJson(new String(Files.readAllBytes(path), StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Serializes {@code manifest} to its deterministic JSON text, ending with a newline. The segment list is written
+ * sorted by path.
+ */
+ public static String toJson(final Manifest m) {
+ final StringWriter out = new StringWriter();
+ try (JsonGenerator g = FACTORY.createGenerator(out)) {
+ final DefaultPrettyPrinter pp = new DefaultPrettyPrinter();
+ final DefaultIndenter indenter = new DefaultIndenter(" ", "\n");
+ pp.indentObjectsWith(indenter);
+ pp.indentArraysWith(indenter);
+ g.setPrettyPrinter(pp);
+
+ g.writeStartObject();
+ g.writeNumberField("formatVersion", m.getFormatVersion());
+ g.writeStringField("sourceId", m.getSourceId());
+ g.writeStringField("sourceVersion", m.getSourceVersion());
+ g.writeStringField("layout", m.getLayout() == null ? null : m.getLayout().name());
+ g.writeNumberField("vertexCount", m.getVertexCount());
+ g.writeNumberField("edgeCount", m.getEdgeCount());
+ g.writeNumberField("vertexOrdinalWidth", m.getVertexOrdinalWidth());
+ g.writeNumberField("edgeOrdinalWidth", m.getEdgeOrdinalWidth());
+ writeStrings(g, "vertexLabels", m.getVertexLabels());
+ writeStrings(g, "edgeLabels", m.getEdgeLabels());
+ writeStrings(g, "vertexKeys", m.getVertexKeys());
+ writeStrings(g, "edgeKeys", m.getEdgeKeys());
+ writeStrings(g, "metaKeys", m.getMetaKeys());
+ writeStrings(g, "variableKeys", m.getVariableKeys());
+ g.writeStringField("vertexLabelLayout", m.getVertexLabelLayout().name());
+ g.writeObjectFieldStart("sourceDefaults");
+ g.writeStringField("defaultVertexLabel", m.getSourceDefaults().defaultVertexLabel());
+ g.writeStringField("defaultEdgeLabel", m.getSourceDefaults().defaultEdgeLabel());
+ g.writeStringField("vertexLabelCardinality", m.getSourceDefaults().vertexLabelCardinality());
+ g.writeStringField("defaultVertexPropertyCardinality",
+ m.getSourceDefaults().defaultVertexPropertyCardinality());
+ g.writeEndObject();
+ g.writeFieldName("vertexIds");
+ writeColumn(g, m.getVertexIds());
+ g.writeFieldName("edgeIds");
+ writeColumn(g, m.getEdgeIds());
+ writeColumns(g, "vertexProperties", m.getVertexProperties());
+ writeColumns(g, "vertexPropertyIds", m.getVertexPropertyIds());
+ writeColumns(g, "edgeProperties", m.getEdgeProperties());
+ g.writeArrayFieldStart("vertexKeyInfos");
+ for (final Manifest.VertexKeyInfo info : m.getVertexKeyInfos()) {
+ g.writeStartObject();
+ g.writeStringField("layout", info.layout().name());
+ g.writeStringField("ownerEncoding", info.ownerEncoding() == null ? null : info.ownerEncoding().name());
+ g.writeNumberField("propertyCount", info.propertyCount());
+ g.writeArrayFieldStart("metaColumns");
+ for (final Map.Entry<Integer, Manifest.ColumnInfo> meta : info.metaColumns().entrySet()) {
+ g.writeStartObject();
+ g.writeNumberField("metaKeyCode", meta.getKey());
+ g.writeFieldName("column");
+ writeColumn(g, meta.getValue());
+ g.writeEndObject();
+ }
+ g.writeEndArray();
+ g.writeEndObject();
+ }
+ g.writeEndArray();
+ g.writeFieldName("variables");
+ writeColumn(g, m.getVariables());
+ writeCounts(g, "vertexLabelCounts", m.getVertexLabelCounts());
+ writeCounts(g, "edgeLabelCounts", m.getEdgeLabelCounts());
+ g.writeBooleanField("outEdgesImplicit", m.isOutEdgesImplicit());
+ g.writeBooleanField("edgeIdIndex", m.isEdgeIdIndex());
+ g.writeBooleanField("vertexIdIndexExact", m.isVertexIdIndexExact());
+ g.writeBooleanField("edgeIdIndexExact", m.isEdgeIdIndexExact());
+
+ final List<Manifest.SegmentInfo> sorted = new ArrayList<>(m.getSegments());
+ sorted.sort(Comparator.comparing(Manifest.SegmentInfo::path));
+ g.writeArrayFieldStart("segments");
+ for (final Manifest.SegmentInfo s : sorted) {
+ g.writeStartObject();
+ g.writeStringField("path", s.path());
+ g.writeNumberField("width", s.width());
+ g.writeNumberField("count", s.count());
+ g.writeNumberField("checksum", s.checksum());
+ g.writeEndObject();
+ }
+ g.writeEndArray();
+ g.writeEndObject();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ return out.toString() + "\n";
+ }
+
+ /**
+ * Parses the JSON text produced by {@link #toJson}.
+ *
+ * @throws UncheckedIOException if the text is not a valid manifest
+ */
+ public static Manifest fromJson(final String json) {
+ final JsonNode root;
+ try {
+ root = MAPPER.readTree(json);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ if (root == null || !root.isObject()) throw invalid("manifest is not a JSON object");
+
+ final Manifest m = new Manifest();
+ m.setFormatVersion(required(root, "formatVersion").asInt());
+ m.setSourceId(optionalText(root, "sourceId"));
+ m.setSourceVersion(optionalText(root, "sourceVersion"));
+ final String layout = optionalText(root, "layout");
+ if (layout != null) {
+ try {
+ m.setLayout(SnapshotLayout.valueOf(layout));
+ } catch (IllegalArgumentException e) {
+ throw invalid("unknown layout " + layout);
+ }
+ }
+ m.setVertexCount(required(root, "vertexCount").asLong());
+ m.setEdgeCount(required(root, "edgeCount").asLong());
+ m.setVertexOrdinalWidth(required(root, "vertexOrdinalWidth").asInt());
+ m.setEdgeOrdinalWidth(required(root, "edgeOrdinalWidth").asInt());
+ m.setVertexLabels(readStrings(required(root, "vertexLabels")));
+ m.setEdgeLabels(readStrings(required(root, "edgeLabels")));
+ m.setVertexKeys(readStrings(required(root, "vertexKeys")));
+ m.setEdgeKeys(readStrings(required(root, "edgeKeys")));
+ m.setMetaKeys(readStrings(required(root, "metaKeys")));
+ m.setVariableKeys(readStrings(required(root, "variableKeys")));
+ m.setVertexLabelLayout(readEnum(Manifest.VertexLabelLayout.class, required(root, "vertexLabelLayout")));
+ final JsonNode defaults = required(root, "sourceDefaults");
+ m.setSourceDefaults(new SourceDefaults(required(defaults, "defaultVertexLabel").asText(),
+ required(defaults, "defaultEdgeLabel").asText(), required(defaults, "vertexLabelCardinality").asText(),
+ required(defaults, "defaultVertexPropertyCardinality").asText()));
+ m.setVertexIds(readColumn(root.get("vertexIds")));
+ m.setEdgeIds(readColumn(root.get("edgeIds")));
+ m.setVertexProperties(readColumns(required(root, "vertexProperties")));
+ m.setVertexPropertyIds(readColumns(required(root, "vertexPropertyIds")));
+ m.setEdgeProperties(readColumns(required(root, "edgeProperties")));
+ final List<Manifest.VertexKeyInfo> keyInfos = new ArrayList<>();
+ for (final JsonNode n : required(root, "vertexKeyInfos")) {
+ final String owner = optionalText(n, "ownerEncoding");
+ final Map<Integer, Manifest.ColumnInfo> metaColumns = new TreeMap<>();
+ for (final JsonNode meta : required(n, "metaColumns")) {
+ metaColumns.put(required(meta, "metaKeyCode").asInt(), readColumn(required(meta, "column")));
+ }
+ keyInfos.add(new Manifest.VertexKeyInfo(readEnum(Manifest.PropertyLayout.class, required(n, "layout")),
+ owner == null ? null : readEnum(Manifest.OwnerEncoding.class, n.get("ownerEncoding")),
+ required(n, "propertyCount").asLong(), metaColumns));
+ }
+ m.setVertexKeyInfos(keyInfos);
+ m.setVariables(readColumn(root.get("variables")));
+ m.setVertexLabelCounts(readCounts(root.get("vertexLabelCounts")));
+ m.setEdgeLabelCounts(readCounts(root.get("edgeLabelCounts")));
+ m.setOutEdgesImplicit(required(root, "outEdgesImplicit").asBoolean());
+ m.setEdgeIdIndex(required(root, "edgeIdIndex").asBoolean());
+ m.setVertexIdIndexExact(required(root, "vertexIdIndexExact").asBoolean());
+ m.setEdgeIdIndexExact(required(root, "edgeIdIndexExact").asBoolean());
+
+ final List<Manifest.SegmentInfo> segments = new ArrayList<>();
+ for (final JsonNode s : required(root, "segments")) {
+ segments.add(new Manifest.SegmentInfo(required(s, "path").asText(), required(s, "width").asInt(),
+ required(s, "count").asLong(), required(s, "checksum").asLong()));
+ }
+ m.setSegments(segments);
+ return m;
+ }
+
+ private static void writeStrings(final JsonGenerator g, final String name, final List<String> values)
+ throws IOException {
+ g.writeArrayFieldStart(name);
+ for (final String v : values) g.writeString(v);
+ g.writeEndArray();
+ }
+
+ // optional: absent when null
+ private static void writeCounts(final JsonGenerator g, final String name, final long[] counts) throws IOException {
+ if (counts == null) return;
+ g.writeArrayFieldStart(name);
+ for (final long c : counts) g.writeNumber(c);
+ g.writeEndArray();
+ }
+
+ private static long[] readCounts(final JsonNode array) {
+ if (array == null || array.isNull()) return null;
+ final long[] out = new long[array.size()];
+ for (int i = 0; i < out.length; i++) out[i] = array.get(i).asLong();
+ return out;
+ }
+
+ private static void writeColumns(final JsonGenerator g, final String name, final List<Manifest.ColumnInfo> columns)
+ throws IOException {
+ g.writeArrayFieldStart(name);
+ for (final Manifest.ColumnInfo c : columns) writeColumn(g, c);
+ g.writeEndArray();
+ }
+
+ private static void writeColumn(final JsonGenerator g, final Manifest.ColumnInfo c) throws IOException {
+ if (c == null) {
+ g.writeNull();
+ return;
+ }
+ g.writeStartObject();
+ g.writeStringField("encoding", c.encoding().name());
+ g.writeArrayFieldStart("valueTypes");
+ for (final ValueType t : c.valueTypes()) g.writeString(t.name());
+ g.writeEndArray();
+ g.writeNumberField("presentCount", c.presentCount());
+ g.writeNumberField("nullCount", c.nullCount());
+ if (c.hasRange()) {
+ g.writeNumberField("minValue", c.minValue());
+ g.writeNumberField("maxValue", c.maxValue());
+ }
+ g.writeEndObject();
+ }
+
+ private static List<String> readStrings(final JsonNode array) {
+ final List<String> out = new ArrayList<>();
+ for (final JsonNode n : array) out.add(n.asText());
+ return out;
+ }
+
+ private static List<Manifest.ColumnInfo> readColumns(final JsonNode array) {
+ final List<Manifest.ColumnInfo> out = new ArrayList<>();
+ for (final JsonNode n : array) out.add(readColumn(n));
+ return out;
+ }
+
+ private static Manifest.ColumnInfo readColumn(final JsonNode n) {
+ if (n == null || n.isNull()) return null;
+ final ColumnEncoding encoding;
+ try {
+ encoding = ColumnEncoding.valueOf(required(n, "encoding").asText());
+ } catch (IllegalArgumentException e) {
+ throw invalid("unknown column encoding " + n.get("encoding"));
+ }
+ final List<ValueType> types = new ArrayList<>();
+ for (final JsonNode t : required(n, "valueTypes")) {
+ try {
+ types.add(ValueType.valueOf(t.asText()));
+ } catch (IllegalArgumentException e) {
+ throw invalid("unknown value type " + t);
+ }
+ }
+ final JsonNode min = n.get("minValue");
+ final JsonNode max = n.get("maxValue");
+ final boolean range = min != null && !min.isNull() && max != null && !max.isNull();
+ return new Manifest.ColumnInfo(encoding, types, required(n, "presentCount").asLong(),
+ required(n, "nullCount").asLong(), range ? min.asLong() : null, range ? max.asLong() : null);
+ }
+
+ private static <E extends Enum<E>> E readEnum(final Class<E> type, final JsonNode n) {
+ try {
+ return Enum.valueOf(type, n.asText());
+ } catch (IllegalArgumentException e) {
+ throw invalid("unknown " + type.getSimpleName() + " " + n);
+ }
+ }
+
+ private static JsonNode required(final JsonNode parent, final String field) {
+ final JsonNode n = parent.get(field);
+ if (n == null || n.isNull()) throw invalid("missing field " + field);
+ return n;
+ }
+
+ private static String optionalText(final JsonNode parent, final String field) {
+ final JsonNode n = parent.get(field);
+ return n == null || n.isNull() ? null : n.asText();
+ }
+
+ private static UncheckedIOException invalid(final String message) {
+ return new UncheckedIOException(new IOException("Invalid manifest: " + message));
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/MappedSegment.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/MappedSegment.java
new file mode 100644
index 0000000000..0c63afba2e
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/MappedSegment.java
@@ -0,0 +1,377 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.MappedByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Objects;
+import java.util.zip.CRC32C;
+
+/**
+ * A memory-mapped segment with random access by value index, in read-only or read-write mode.
+ * <p/>
+ * The file is mapped in fixed regions of {@link #regionBytes()} bytes, counted from file offset 0 so that the header
+ * is part of region 0. The region size is a power of two of at least {@link SegmentHeader#SIZE}, and so is a
+ * multiple of every value width, as is the header size. A fixed-width value therefore never crosses a region
+ * boundary, and segments larger than 2 GiB need no special handling. Only {@link #getBytes} and {@link #putBytes},
+ * meant for the width-1 payload of variable-width columns, copy across region boundaries.
+ * <p/>
+ * Typed accessors take a value index in {@code [0, count())} and must match the segment's value width. They throw
+ * {@link IndexOutOfBoundsException} for a bad index and {@link IllegalStateException} for a width mismatch. Writes
+ * on a read-only segment throw {@link java.nio.ReadOnlyBufferException}.
+ * <p/>
+ * A read-write segment created with {@link #create} is zero-filled, has the count in its header, and has a zero
+ * checksum until {@link #finish()} computes the real one. A segment that was created or reopened read-write and never
+ * finished is therefore detectable through {@link #verifyChecksum()}. Java offers no portable way to unmap a buffer:
+ * {@link #close()} flushes a read-write segment and releases the references, and the operating system reclaims the
+ * mapping when the buffers are garbage collected. The file may be deleted or renamed in the meantime on POSIX
+ * systems. Accessors must not be called after {@link #close()}.
+ * <p/>
+ * Instances may be read from several threads; concurrent writes to distinct values are safe, as for any
+ * {@link MappedByteBuffer}, provided the callers do not write the same value concurrently.
+ */
+public final class MappedSegment implements AutoCloseable {
+
+ /**
+ * The default region size, 1 GiB.
+ */
+ public static final long DEFAULT_REGION_BYTES = 1L << 30;
+
+ /**
+ * The largest supported region size, 1 GiB, so that a region fits a {@link ByteBuffer}.
+ */
+ public static final long MAX_REGION_BYTES = 1L << 30;
+
+ public enum Mode {
+ READ_ONLY, READ_WRITE
+ }
+
+ private final Path path;
+ private final Mode mode;
+ private final int valueWidth;
+ private final long count;
+ private final long regionBytes;
+ private final int regionShift;
+ private final long regionMask;
+ private final MappedByteBuffer[] regions;
+ private long checksum;
+
+ private MappedSegment(final Path path, final Mode mode, final SegmentHeader header, final long regionBytes,
+ final FileChannel channel) throws IOException {
+ this.path = path;
+ this.mode = mode;
+ this.valueWidth = header.valueWidth();
+ this.count = header.count();
+ this.checksum = header.checksum();
+ this.regionBytes = regionBytes;
+ this.regionShift = Long.numberOfTrailingZeros(regionBytes);
+ this.regionMask = regionBytes - 1;
+ final long fileBytes = header.fileBytes();
+ final int regionCount = (int) ((fileBytes + regionBytes - 1) >>> regionShift);
+ this.regions = new MappedByteBuffer[regionCount];
+ final FileChannel.MapMode mapMode = mode == Mode.READ_ONLY ? FileChannel.MapMode.READ_ONLY
+ : FileChannel.MapMode.READ_WRITE;
+ for (int i = 0; i < regionCount; i++) {
+ final long start = (long) i << regionShift;
+ final long size = Math.min(regionBytes, fileBytes - start);
+ regions[i] = channel.map(mapMode, start, size);
+ regions[i].order(ByteOrder.LITTLE_ENDIAN);
+ }
+ }
+
+ /**
+ * Maps an existing segment with the {@linkplain #DEFAULT_REGION_BYTES default region size}. The header and the file
+ * length are validated.
+ *
+ * @throws UncheckedIOException if the file cannot be mapped or is not a well-formed segment
+ */
+ public static MappedSegment open(final Path path, final Mode mode) {
+ return open(path, mode, DEFAULT_REGION_BYTES);
+ }
+
+ /**
+ * Maps an existing segment with an explicit region size, so small regions can be exercised.
+ *
+ * @param regionBytes a power of two in {@code [SegmentHeader.SIZE, MAX_REGION_BYTES]}
+ * @throws UncheckedIOException if the file cannot be mapped or is not a well-formed segment
+ */
+ public static MappedSegment open(final Path path, final Mode mode, final long regionBytes) {
+ Objects.requireNonNull(mode);
+ checkRegionBytes(regionBytes);
+ final StandardOpenOption[] options = mode == Mode.READ_ONLY
+ ? new StandardOpenOption[]{StandardOpenOption.READ}
+ : new StandardOpenOption[]{StandardOpenOption.READ, StandardOpenOption.WRITE};
+ try (FileChannel channel = FileChannel.open(path, options)) {
+ final SegmentHeader header = SegmentHeader.read(channel, path);
+ return new MappedSegment(path, mode, header, regionBytes, channel);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Creates a zero-filled read-write segment of {@code count} values, replacing any existing file and creating
+ * missing parent directories, with the {@linkplain #DEFAULT_REGION_BYTES default region size}. The file is
+ * sparse where the filesystem supports it. Call {@link #finish()} when all values have been written.
+ *
+ * @param valueWidth the value width in bytes: 1, 2, 4, 8 or 16
+ */
+ public static MappedSegment create(final Path path, final int valueWidth, final long count) {
+ return create(path, valueWidth, count, DEFAULT_REGION_BYTES);
+ }
+
+ /**
+ * As {@link #create(Path, int, long)} with an explicit region size.
+ *
+ * @param regionBytes a power of two in {@code [SegmentHeader.SIZE, MAX_REGION_BYTES]}
+ */
+ public static MappedSegment create(final Path path, final int valueWidth, final long count, final long regionBytes) {
+ checkRegionBytes(regionBytes);
+ final SegmentHeader header = new SegmentHeader(valueWidth, count, 0);
+ try {
+ final Path parent = path.toAbsolutePath().getParent();
+ if (parent != null) Files.createDirectories(parent);
+ Files.deleteIfExists(path);
+ try (RandomAccessFile file = new RandomAccessFile(path.toFile(), "rw")) {
+ file.setLength(header.fileBytes());
+ }
+ try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
+ final ByteBuffer h = header.toBuffer();
+ while (h.hasRemaining()) channel.write(h, h.position());
+ return new MappedSegment(path, Mode.READ_WRITE, header, regionBytes, channel);
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private static void checkRegionBytes(final long regionBytes) {
+ if (regionBytes < SegmentHeader.SIZE || regionBytes > MAX_REGION_BYTES
+ || Long.bitCount(regionBytes) != 1) {
+ throw new IllegalArgumentException("Region size must be a power of two in [" + SegmentHeader.SIZE
+ + ", " + MAX_REGION_BYTES + "]: " + regionBytes);
+ }
+ }
+
+ public Path path() {
+ return path;
+ }
+
+ public Mode mode() {
+ return mode;
+ }
+
+ public int valueWidth() {
+ return valueWidth;
+ }
+
+ /**
+ * The number of values in the segment.
+ */
+ public long count() {
+ return count;
+ }
+
+ public long regionBytes() {
+ return regionBytes;
+ }
+
+ /**
+ * The number of mapped regions, including the one holding the header.
+ */
+ public int regionCount() {
+ return regions.length;
+ }
+
+ /**
+ * The header checksum as of the last {@link #finish()}, or as read when the segment was opened.
+ */
+ public long checksum() {
+ return checksum;
+ }
+
+ /**
+ * Describes the segment for the manifest, using the current {@link #checksum()}.
+ *
+ * @param relativePath the path relative to the bundle root, using {@code '/'} separators
+ */
+ public Manifest.SegmentInfo info(final String relativePath) {
+ return new Manifest.SegmentInfo(relativePath, valueWidth, count, checksum);
+ }
+
+ public byte getByte(final long index) {
+ final long off = offset(index, 1);
+ return regions[(int) (off >>> regionShift)].get((int) (off & regionMask));
+ }
+
+ public short getShort(final long index) {
+ final long off = offset(index, 2);
+ return regions[(int) (off >>> regionShift)].getShort((int) (off & regionMask));
+ }
+
+ public int getInt(final long index) {
+ final long off = offset(index, 4);
+ return regions[(int) (off >>> regionShift)].getInt((int) (off & regionMask));
+ }
+
+ public long getLong(final long index) {
+ final long off = offset(index, 8);
+ return regions[(int) (off >>> regionShift)].getLong((int) (off & regionMask));
+ }
+
+ public void putByte(final long index, final byte value) {
+ final long off = offset(index, 1);
+ regions[(int) (off >>> regionShift)].put((int) (off & regionMask), value);
+ }
+
+ public void putShort(final long index, final short value) {
+ final long off = offset(index, 2);
+ regions[(int) (off >>> regionShift)].putShort((int) (off & regionMask), value);
+ }
+
+ public void putInt(final long index, final int value) {
+ final long off = offset(index, 4);
+ regions[(int) (off >>> regionShift)].putInt((int) (off & regionMask), value);
+ }
+
+ public void putLong(final long index, final long value) {
+ final long off = offset(index, 8);
+ regions[(int) (off >>> regionShift)].putLong((int) (off & regionMask), value);
+ }
+
+ /**
+ * Copies {@code length} values of a width-1 segment, starting at {@code startIndex}, into {@code dst}. The range
+ * may span region boundaries.
+ */
+ public void getBytes(final long startIndex, final byte[] dst, final int offset, final int length) {
+ copy(startIndex, dst, offset, length, false);
+ }
+
+ /**
+ * Copies {@code length} bytes from {@code src} into a width-1 segment starting at {@code startIndex}. The range
+ * may span region boundaries.
+ */
+ public void putBytes(final long startIndex, final byte[] src, final int offset, final int length) {
+ copy(startIndex, src, offset, length, true);
+ }
+
+ /**
+ * Recomputes the CRC32C of the payload, stores it in the header, and flushes the segment to disk. Call after all
+ * values are written. Read-write mode only.
+ *
+ * @throws UnsupportedOperationException on a read-only segment
+ */
+ public void finish() {
+ if (mode != Mode.READ_WRITE) throw new UnsupportedOperationException("Segment is read-only: " + path);
+ force();
+ checksum = computeChecksum();
+ final SegmentHeader header = new SegmentHeader(valueWidth, count, checksum);
+ final ByteBuffer h = header.toBuffer();
+ final MappedByteBuffer first = regions[0];
+ for (int i = 0; i < SegmentHeader.SIZE; i++) first.put(i, h.get(i));
+ force();
+ }
+
+ /**
+ * Flushes modified regions to disk. A no-op in read-only mode.
+ */
+ public void force() {
+ if (mode == Mode.READ_WRITE) {
+ for (final MappedByteBuffer region : regions) region.force();
+ }
+ }
+
+ /**
+ * Computes the CRC32C of the payload from the mapped data.
+ */
+ public long computeChecksum() {
+ final CRC32C crc = new CRC32C();
+ for (int i = 0; i < regions.length; i++) {
+ final ByteBuffer dup = regions[i].duplicate();
+ dup.clear();
+ if (i == 0) dup.position(SegmentHeader.SIZE);
+ crc.update(dup);
+ }
+ return crc.getValue();
+ }
+
+ /**
+ * Compares {@link #computeChecksum()} with the header checksum.
+ *
+ * @throws UncheckedIOException if they differ
+ */
+ public void verifyChecksum() {
+ final long actual = computeChecksum();
+ if (actual != checksum) {
+ throw SegmentHeader.corrupt(path, "checksum mismatch, header " + checksum + " computed " + actual);
+ }
+ }
+
+ /**
+ * Flushes a read-write segment. The mapping is released when the buffers are garbage collected.
+ */
+ @Override
+ public void close() {
+ force();
+ }
+
+ // byte offset in the file of the value at index, after checking width and bounds
+ private long offset(final long index, final int width) {
+ if (valueWidth != width) {
+ throw new IllegalStateException("Segment " + path + " has value width " + valueWidth + ", not " + width);
+ }
+ Objects.checkIndex(index, count);
+ return SegmentHeader.SIZE + index * width;
+ }
+
+ private void copy(final long startIndex, final byte[] array, final int arrayOffset, final int length,
+ final boolean write) {
+ if (valueWidth != 1) {
+ throw new IllegalStateException("Segment " + path + " has value width " + valueWidth + ", not 1");
+ }
+ Objects.checkFromIndexSize(arrayOffset, length, array.length);
+ Objects.checkFromIndexSize(startIndex, length, count);
+ long off = SegmentHeader.SIZE + startIndex;
+ int arrayPos = arrayOffset;
+ int remaining = length;
+ while (remaining > 0) {
+ final ByteBuffer region = regions[(int) (off >>> regionShift)];
+ final int inRegion = (int) (off & regionMask);
+ final int n = (int) Math.min(remaining, region.limit() - inRegion);
+ final ByteBuffer view = region.duplicate();
+ view.position(inRegion);
+ if (write) {
+ view.put(array, arrayPos, n);
+ } else {
+ view.get(array, arrayPos, n);
+ }
+ off += n;
+ arrayPos += n;
+ remaining -= n;
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/OwnerCursor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/OwnerCursor.java
new file mode 100644
index 0000000000..3c1a585c09
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/OwnerCursor.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.util.Objects;
+
+/**
+ * Finds the range of vertex-property ordinals a vertex owns for one key, for ascending vertex ordinals, without a
+ * binary search per vertex. For a multi key it walks {@code owner-ordinals.bin} sequentially (or indexes
+ * {@code owner-offsets.bin} directly for dense owners); for a single key it wraps a {@link ColumnCursor} over the
+ * value column. After {@link #seek(int)}, {@link #start()} and {@link #end()} give the half-open range, empty when the
+ * vertex has no vertex property of the key. A vertex below the previous one is answered by a binary search. Instances
+ * are stateful and not thread-safe; use one per scan.
+ */
+public final class OwnerCursor {
+
+ private final ColumnCursor single;
+ private final MappedSegment offsets;
+ private final MappedSegment ordinals;
+ private final int vertexCount;
+ private long owner;
+ private int lastVertex = -1;
+ private long start;
+ private long end;
+
+ private OwnerCursor(final ColumnCursor single, final MappedSegment offsets, final MappedSegment ordinals,
+ final int vertexCount) {
+ this.single = single;
+ this.offsets = offsets;
+ this.ordinals = ordinals;
+ this.vertexCount = vertexCount;
+ }
+
+ /**
+ * A cursor over a single-layout key, whose vertex-property ordinal is the entry index of the value column.
+ */
+ public static OwnerCursor ofColumn(final ColumnReader column) {
+ return new OwnerCursor(column.cursor(), null, null, 0);
+ }
+
+ /**
+ * A cursor over a multi-layout key.
+ *
+ * @param offsets {@code owner-offsets.bin}
+ * @param ordinals {@code owner-ordinals.bin}, or null for dense owners
+ */
+ public static OwnerCursor ofOwners(final MappedSegment offsets, final MappedSegment ordinals,
+ final int vertexCount) {
+ return new OwnerCursor(null, Objects.requireNonNull(offsets), ordinals, vertexCount);
+ }
+
+ /**
+ * Positions the cursor at the vertex.
+ *
+ * @return true if the vertex has at least one vertex property of the key
+ */
+ public boolean seek(final int vertex) {
+ if (single != null) {
+ final long entry = single.seek(vertex);
+ start = entry < 0 ? 0 : entry;
+ end = entry < 0 ? 0 : entry + 1;
+ return entry >= 0;
+ }
+ if (ordinals == null) {
+ Objects.checkIndex(vertex, vertexCount);
+ start = offsets.getLong(vertex);
+ end = offsets.getLong(vertex + 1L);
+ return end > start;
+ }
+ final long count = ordinals.count();
+ if (vertex < lastVertex) owner = lowerBound(vertex);
+ lastVertex = vertex;
+ while (owner < count && ordinals.getInt(owner) < vertex) owner++;
+ if (owner < count && ordinals.getInt(owner) == vertex) {
+ start = offsets.getLong(owner);
+ end = offsets.getLong(owner + 1);
+ return true;
+ }
+ start = 0;
+ end = 0;
+ return false;
+ }
+
+ private long lowerBound(final int vertex) {
+ long lo = 0;
+ long hi = ordinals.count();
+ while (lo < hi) {
+ final long mid = (lo + hi) >>> 1;
+ if (ordinals.getInt(mid) < vertex) lo = mid + 1;
+ else hi = mid;
+ }
+ return lo;
+ }
+
+ /**
+ * The first vertex-property ordinal of the last seeked vertex.
+ */
+ public long start() {
+ return start;
+ }
+
+ /**
+ * One past the last vertex-property ordinal of the last seeked vertex.
+ */
+ public long end() {
+ return end;
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/OwnerWriter.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/OwnerWriter.java
new file mode 100644
index 0000000000..4aab59f563
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/OwnerWriter.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Writes the owner files of a vertex property key in the multi layout, which map vertices to the ranges of
+ * vertex-property ordinals they own. Owners are dense when {@code ownerCount * 4 >= vertexCount}, see
+ * {@link #isDense(long, long)}:
+ * <ul>
+ * <li>dense owners: {@code owner-offsets.bin}, int64 with {@code vertexCount + 1} entries, where a vertex with no
+ * vertex properties for the key has an empty range;</li>
+ * <li>sparse owners: {@code owner-ordinals.bin}, int32 with the owning vertex ordinals in ascending order, and
+ * {@code owner-offsets.bin}, int64 with {@code ownerCount + 1} entries.</li>
+ * </ul>
+ * The ordinals of one vertex's vertex properties are consecutive and start at the running total, so the offsets are
+ * prefix sums of the per-owner property counts. Both builders use this class, so the bytes are identical. For each
+ * vertex that has at least one vertex property for the key, in ascending vertex ordinal order, call
+ * {@link #owner(long, long)}. The owner count must be known when the writer is created. Instances are not
+ * thread-safe.
+ */
+public final class OwnerWriter implements AutoCloseable {
+
+ private final String offsetsPath;
+ private final String ordinalsPath;
+ private final long vertexCount;
+ private final long ownerCount;
+ private final boolean dense;
+ private final SegmentWriter offsets;
+ private final SegmentWriter ordinals;
+ private long owners;
+ private long nextVertex;
+ private long total;
+ private long lastVertex = -1;
+ private List<Manifest.SegmentInfo> segments;
+
+ private OwnerWriter(final Function<String, Path> resolver, final int keyCode, final long vertexCount,
+ final long ownerCount) {
+ this.offsetsPath = SegmentPaths.vertexPropertyOwnerOffsets(keyCode);
+ this.ordinalsPath = SegmentPaths.vertexPropertyOwnerOrdinals(keyCode);
+ this.vertexCount = vertexCount;
+ this.ownerCount = ownerCount;
+ this.dense = isDense(vertexCount, ownerCount);
+ this.offsets = SegmentWriter.create(resolver.apply(offsetsPath), 8);
+ SegmentWriter o = null;
+ try {
+ if (!dense) o = SegmentWriter.create(resolver.apply(ordinalsPath), 4);
+ } catch (RuntimeException e) {
+ offsets.close();
+ throw e;
+ }
+ this.ordinals = o;
+ offsets.writeLong(0);
+ }
+
+ /**
+ * Whether owners are dense, that is {@code ownerCount * 4 >= vertexCount}, the rule used for columns with the
+ * owner count in place of the present count.
+ */
+ public static boolean isDense(final long vertexCount, final long ownerCount) {
+ return ColumnStats.isDense(vertexCount, ownerCount);
+ }
+
+ /**
+ * The owner encoding for the given counts.
+ */
+ public static Manifest.OwnerEncoding encodingOf(final long vertexCount, final long ownerCount) {
+ return isDense(vertexCount, ownerCount) ? Manifest.OwnerEncoding.DENSE : Manifest.OwnerEncoding.SPARSE;
+ }
+
+ /**
+ * Creates the writer for one vertex property key.
+ *
+ * @param resolver maps a path relative to the bundle root (or the scratch root) to a file, for example
+ * {@code BuildDirectory::segmentPath}
+ * @param keyCode the vertex property key code
+ * @param vertexCount the number of vertices
+ * @param ownerCount the number of vertices that have at least one vertex property for the key
+ */
+ public static OwnerWriter create(final Function<String, Path> resolver, final int keyCode, final long vertexCount,
+ final long ownerCount) {
+ if (ownerCount < 0 || ownerCount > vertexCount) {
+ throw new IllegalArgumentException("Key " + keyCode + " has " + ownerCount + " owners among "
+ + vertexCount + " vertices");
+ }
+ return new OwnerWriter(resolver, keyCode, vertexCount, ownerCount);
+ }
+
+ /**
+ * Adds an owner.
+ *
+ * @param vertexOrdinal the owning vertex, greater than the previous owner and less than the vertex count
+ * @param propertyCount the number of vertex properties the vertex has for the key, at least 1
+ */
+ public void owner(final long vertexOrdinal, final long propertyCount) {
+ if (vertexOrdinal <= lastVertex || vertexOrdinal >= vertexCount) {
+ throw new IllegalArgumentException("Owner " + vertexOrdinal + " is not in (" + lastVertex + ", "
+ + vertexCount + ")");
+ }
+ if (propertyCount < 1) throw new IllegalArgumentException("An owner needs at least one vertex property");
+ if (owners >= ownerCount) throw new IllegalStateException("More owners than the announced " + ownerCount);
+ lastVertex = vertexOrdinal;
+ if (dense) {
+ while (nextVertex < vertexOrdinal) {
+ offsets.writeLong(total);
+ nextVertex++;
+ }
+ nextVertex = vertexOrdinal + 1;
+ } else {
+ ordinals.writeInt((int) vertexOrdinal);
+ }
+ total += propertyCount;
+ offsets.writeLong(total);
+ owners++;
+ }
+
+ /**
+ * The number of vertex properties added so far, which is the next vertex-property ordinal.
+ */
+ public long propertyCount() {
+ return total;
+ }
+
+ /**
+ * Completes the files.
+ *
+ * @param expectedPropertyCount the key's vertex-property count, checked against the sum of the owners' counts
+ * @return the segments written, in ascending path order
+ */
+ public List<Manifest.SegmentInfo> finish(final long expectedPropertyCount) {
+ if (segments != null) return segments;
+ if (owners != ownerCount) {
+ throw new IllegalStateException("Received " + owners + " owners, expected " + ownerCount);
+ }
+ if (total != expectedPropertyCount) {
+ throw new IllegalStateException("Owners hold " + total + " vertex properties, expected "
+ + expectedPropertyCount);
+ }
+ if (dense) {
+ while (nextVertex < vertexCount) {
+ offsets.writeLong(total);
+ nextVertex++;
+ }
+ }
+ offsets.finish();
+ final List<Manifest.SegmentInfo> out = new ArrayList<>();
+ out.add(offsets.info(offsetsPath));
+ if (ordinals != null) {
+ ordinals.finish();
+ out.add(ordinals.info(ordinalsPath));
+ }
+ out.sort(Comparator.comparing(Manifest.SegmentInfo::path));
+ segments = out;
+ return segments;
+ }
+
+ @Override
+ public void close() {
+ offsets.close();
+ if (ordinals != null) ordinals.close();
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentHeader.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentHeader.java
new file mode 100644
index 0000000000..adb140906e
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentHeader.java
@@ -0,0 +1,239 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.zip.CRC32C;
+
+/**
+ * The 64-byte header at the start of every {@code .bin} segment, including scratch spools. All multi-byte fields are
+ * little-endian.
+ *
+ * <pre>
+ * offset size field
+ * 0 8 magic, ASCII TPCSRSEG
+ * 8 4 format version
+ * 12 1 byte order, 0 for little-endian
+ * 13 3 reserved, zero
+ * 16 4 value width in bytes: 1, 2, 4, 8 or 16
+ * 20 4 reserved, zero
+ * 24 8 value count
+ * 32 8 CRC32C of the payload, in the low 32 bits
+ * 40 24 reserved, zero
+ * </pre>
+ *
+ * The payload starts at byte {@link #SIZE}. The file length of a well-formed segment is exactly
+ * {@code SIZE + count * width}. A header that has not been finalized is all zero, so it fails the magic check.
+ */
+public final class SegmentHeader {
+
+ /**
+ * Header size in bytes, and the file offset of the payload.
+ */
+ public static final int SIZE = 64;
+
+ /**
+ * The magic value at offset 0.
+ */
+ public static final byte[] MAGIC = "TPCSRSEG".getBytes(StandardCharsets.US_ASCII);
+
+ /**
+ * The value of the byte-order field for little-endian, the only value written.
+ */
+ public static final byte LITTLE_ENDIAN = 0;
+
+ private static final int VERSION_OFFSET = 8;
+ private static final int BYTE_ORDER_OFFSET = 12;
+ private static final int WIDTH_OFFSET = 16;
+ private static final int COUNT_OFFSET = 24;
+ private static final int CHECKSUM_OFFSET = 32;
+
+ private final int valueWidth;
+ private final long count;
+ private final long checksum;
+
+ /**
+ * @param valueWidth the value width in bytes: 1, 2, 4, 8 or 16
+ * @param count the number of values in the payload
+ * @param checksum the CRC32C of the payload in the low 32 bits
+ */
+ public SegmentHeader(final int valueWidth, final long count, final long checksum) {
+ if (!isValidWidth(valueWidth)) throw new IllegalArgumentException("Invalid value width: " + valueWidth);
+ if (count < 0) throw new IllegalArgumentException("Negative value count: " + count);
+ this.valueWidth = valueWidth;
+ this.count = count;
+ this.checksum = checksum & 0xFFFFFFFFL;
+ }
+
+ public int valueWidth() {
+ return valueWidth;
+ }
+
+ public long count() {
+ return count;
+ }
+
+ /**
+ * The stored CRC32C of the payload, in the range {@code [0, 2^32)}.
+ */
+ public long checksum() {
+ return checksum;
+ }
+
+ /**
+ * The payload length in bytes, {@code count * valueWidth}.
+ *
+ * @throws ArithmeticException if the length overflows a long
+ */
+ public long payloadBytes() {
+ return Math.multiplyExact(count, (long) valueWidth);
+ }
+
+ /**
+ * The expected length of the whole file in bytes.
+ */
+ public long fileBytes() {
+ return Math.addExact(SIZE, payloadBytes());
+ }
+
+ public static boolean isValidWidth(final int width) {
+ return width == 1 || width == 2 || width == 4 || width == 8 || width == 16;
+ }
+
+ /**
+ * Serializes this header to a new little-endian 64-byte buffer positioned at 0 with limit 64.
+ */
+ public ByteBuffer toBuffer() {
+ final ByteBuffer b = ByteBuffer.allocate(SIZE).order(ByteOrder.LITTLE_ENDIAN);
+ b.put(0, MAGIC);
+ b.putInt(VERSION_OFFSET, Manifest.FORMAT_VERSION);
+ b.put(BYTE_ORDER_OFFSET, LITTLE_ENDIAN);
+ b.putInt(WIDTH_OFFSET, valueWidth);
+ b.putLong(COUNT_OFFSET, count);
+ b.putLong(CHECKSUM_OFFSET, checksum);
+ return b;
+ }
+
+ /**
+ * Parses and validates a header from {@code src}, starting at its current position and without changing it.
+ *
+ * @param src a buffer with at least {@link #SIZE} bytes remaining
+ * @param path the segment path, used only in error messages
+ * @throws UncheckedIOException if the bytes are not a valid header
+ */
+ public static SegmentHeader parse(final ByteBuffer src, final Path path) {
+ if (src.remaining() < SIZE) throw corrupt(path, "file is shorter than the " + SIZE + " byte header");
+ final ByteBuffer b = src.duplicate().order(ByteOrder.LITTLE_ENDIAN);
+ final int base = b.position();
+ for (int i = 0; i < MAGIC.length; i++) {
+ if (b.get(base + i) != MAGIC[i]) throw corrupt(path, "bad magic");
+ }
+ final int version = b.getInt(base + VERSION_OFFSET);
+ if (version != Manifest.FORMAT_VERSION) {
+ throw corrupt(path, "unsupported format version " + version);
+ }
+ final byte order = b.get(base + BYTE_ORDER_OFFSET);
+ if (order != LITTLE_ENDIAN) throw corrupt(path, "unsupported byte order " + order);
+ final int width = b.getInt(base + WIDTH_OFFSET);
+ if (!isValidWidth(width)) throw corrupt(path, "invalid value width " + width);
+ final long count = b.getLong(base + COUNT_OFFSET);
+ if (count < 0) throw corrupt(path, "negative value count " + count);
+ final long checksum = b.getLong(base + CHECKSUM_OFFSET);
+ return new SegmentHeader(width, count, checksum);
+ }
+
+ /**
+ * Reads and validates the header of the segment at {@code path}, and checks that the file length matches it.
+ *
+ * @throws UncheckedIOException if the file cannot be read or is not a well-formed segment
+ */
+ public static SegmentHeader read(final Path path) {
+ try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ)) {
+ return read(ch, path);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Reads and validates the header through an open channel, and checks that the file length matches it.
+ *
+ * @throws UncheckedIOException if the file cannot be read or is not a well-formed segment
+ */
+ public static SegmentHeader read(final FileChannel channel, final Path path) throws IOException {
+ final ByteBuffer b = ByteBuffer.allocate(SIZE);
+ int pos = 0;
+ while (b.hasRemaining()) {
+ final int n = channel.read(b, pos);
+ if (n < 0) throw corrupt(path, "file is shorter than the " + SIZE + " byte header");
+ pos += n;
+ }
+ b.flip();
+ final SegmentHeader header = parse(b, path);
+ final long expected;
+ try {
+ expected = header.fileBytes();
+ } catch (ArithmeticException e) {
+ throw corrupt(path, "payload length overflows");
+ }
+ final long actual = channel.size();
+ if (actual != expected) {
+ throw corrupt(path, "file length " + actual + " does not match header, expected " + expected);
+ }
+ return header;
+ }
+
+ /**
+ * Computes the CRC32C of {@code payloadBytes} bytes starting at file offset {@link #SIZE}, reading through
+ * positional reads so the channel's own position is untouched.
+ */
+ public static long computeChecksum(final FileChannel channel, final long payloadBytes) throws IOException {
+ final CRC32C crc = new CRC32C();
+ final ByteBuffer buf = ByteBuffer.allocateDirect(1 << 20);
+ long pos = SIZE;
+ long remaining = payloadBytes;
+ while (remaining > 0) {
+ buf.clear();
+ if (remaining < buf.capacity()) buf.limit((int) remaining);
+ final int n = channel.read(buf, pos);
+ if (n < 0) throw new IOException("Unexpected end of file while computing checksum");
+ buf.flip();
+ crc.update(buf);
+ pos += n;
+ remaining -= n;
+ }
+ return crc.getValue();
+ }
+
+ static UncheckedIOException corrupt(final Path path, final String message) {
+ return new UncheckedIOException(new IOException("Corrupt segment " + path + ": " + message));
+ }
+
+ @Override
+ public String toString() {
+ return "SegmentHeader{width=" + valueWidth + ", count=" + count + ", checksum=" + checksum + "}";
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentPaths.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentPaths.java
new file mode 100644
index 0000000000..8aeb79e5a5
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentPaths.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+/**
+ * Relative paths of the segments in a snapshot bundle. All paths use {@code '/'} as the separator and are relative to
+ * the bundle root. Directories named by property-key code use the code's decimal form.
+ */
+public final class SegmentPaths {
+
+ private SegmentPaths() {
+ }
+
+ public static final String SEPARATOR = "/";
+
+ public static final String MANIFEST = "manifest.json";
+
+ /**
+ * Scratch directory under the build directory for spools and anything the layout does not publish.
+ */
+ public static final String SCRATCH_DIR = "scratch";
+
+ // file extension of every segment
+ public static final String SEGMENT_EXTENSION = ".bin";
+
+ // top-level directories
+ public static final String VERTICES_DIR = "vertices";
+ public static final String EDGES_DIR = "edges";
+ public static final String ADJACENCY_DIR = "adjacency";
+ public static final String PROPERTIES_DIR = "properties";
+
+ // vertices/ and edges/
+ public static final String IDS_DIR = "ids";
+ public static final String LABELS = "labels.bin";
+ public static final String ID_INDEX_KEYS = "id-index-keys.bin";
+ public static final String ID_INDEX_ORDINALS = "id-index-ordinals.bin";
+ public static final String OUT_VERTICES = "out-vertices.bin";
+ public static final String IN_VERTICES = "in-vertices.bin";
+
+ public static final String VERTEX_IDS_DIR = VERTICES_DIR + SEPARATOR + IDS_DIR;
+ public static final String VERTEX_LABELS = VERTICES_DIR + SEPARATOR + LABELS;
+ // multi vertex label layout: int64 offsets (V + 1) into narrowed label codes
+ public static final String LABEL_OFFSETS = "label-offsets.bin";
+ public static final String LABEL_CODES = "label-codes.bin";
+ public static final String VERTEX_LABEL_OFFSETS = VERTICES_DIR + SEPARATOR + LABEL_OFFSETS;
+ public static final String VERTEX_LABEL_CODES = VERTICES_DIR + SEPARATOR + LABEL_CODES;
+ public static final String VERTEX_ID_INDEX_KEYS = VERTICES_DIR + SEPARATOR + ID_INDEX_KEYS;
+ public static final String VERTEX_ID_INDEX_ORDINALS = VERTICES_DIR + SEPARATOR + ID_INDEX_ORDINALS;
+
+ public static final String EDGE_IDS_DIR = EDGES_DIR + SEPARATOR + IDS_DIR;
+ public static final String EDGE_LABELS = EDGES_DIR + SEPARATOR + LABELS;
+ public static final String EDGE_OUT_VERTICES = EDGES_DIR + SEPARATOR + OUT_VERTICES;
+ public static final String EDGE_IN_VERTICES = EDGES_DIR + SEPARATOR + IN_VERTICES;
+ public static final String EDGE_ID_INDEX_KEYS = EDGES_DIR + SEPARATOR + ID_INDEX_KEYS;
+ public static final String EDGE_ID_INDEX_ORDINALS = EDGES_DIR + SEPARATOR + ID_INDEX_ORDINALS;
+
+ // adjacency/
+ public static final String OUT_OFFSETS = ADJACENCY_DIR + SEPARATOR + "out-offsets.bin";
+ public static final String OUT_NEIGHBORS = ADJACENCY_DIR + SEPARATOR + "out-neighbors.bin";
+ public static final String OUT_EDGES = ADJACENCY_DIR + SEPARATOR + "out-edges.bin";
+ public static final String IN_OFFSETS = ADJACENCY_DIR + SEPARATOR + "in-offsets.bin";
+ public static final String IN_NEIGHBORS = ADJACENCY_DIR + SEPARATOR + "in-neighbors.bin";
+ public static final String IN_EDGES = ADJACENCY_DIR + SEPARATOR + "in-edges.bin";
+
+ // properties/
+ public static final String VERTEX_PROPERTIES_DIR = PROPERTIES_DIR + SEPARATOR + "vertex";
+ public static final String EDGE_PROPERTIES_DIR = PROPERTIES_DIR + SEPARATOR + "edge";
+
+ // multi vertex-property layout, inside properties/vertex/<keyCode>/
+ public static final String OWNER_OFFSETS = "owner-offsets.bin";
+ public static final String OWNER_ORDINALS = "owner-ordinals.bin";
+ public static final String META_DIR = "meta";
+
+ // graph variables
+ public static final String VARIABLES_DIR = "variables";
+
+ // files inside a column directory
+ public static final String PRESENCE = "presence.bin";
+ public static final String ORDINALS = "ordinals.bin";
+ public static final String VALUES = "values.bin";
+ public static final String OFFSETS = "offsets.bin";
+ public static final String DATA = "data.bin";
+ public static final String TYPES = "types.bin";
+ public static final String NULLS = "nulls.bin";
+
+ /**
+ * Directory of the column for a vertex property key, for example {@code properties/vertex/3}.
+ */
+ public static String vertexPropertyDir(final int keyCode) {
+ return VERTEX_PROPERTIES_DIR + SEPARATOR + keyCode;
+ }
+
+ /**
+ * Directory of the vertex-property identifier column for a key, for example {@code properties/vertex/3/ids}.
+ */
+ public static String vertexPropertyIdsDir(final int keyCode) {
+ return vertexPropertyDir(keyCode) + SEPARATOR + IDS_DIR;
+ }
+
+ /**
+ * Path of {@code owner-offsets.bin} of a multi-layout vertex property key.
+ */
+ public static String vertexPropertyOwnerOffsets(final int keyCode) {
+ return file(vertexPropertyDir(keyCode), OWNER_OFFSETS);
+ }
+
+ /**
+ * Path of {@code owner-ordinals.bin} of a multi-layout vertex property key with sparse owners.
+ */
+ public static String vertexPropertyOwnerOrdinals(final int keyCode) {
+ return file(vertexPropertyDir(keyCode), OWNER_ORDINALS);
+ }
+
+ /**
+ * Directory of a meta-property column, for example {@code properties/vertex/3/meta/1}.
+ */
+ public static String vertexPropertyMetaDir(final int keyCode, final int metaKeyCode) {
+ return vertexPropertyDir(keyCode) + SEPARATOR + META_DIR + SEPARATOR + metaKeyCode;
+ }
+
+ /**
+ * Directory of the column for an edge property key, for example {@code properties/edge/3}.
+ */
+ public static String edgePropertyDir(final int keyCode) {
+ return EDGE_PROPERTIES_DIR + SEPARATOR + keyCode;
+ }
+
+ /**
+ * Path of a file inside a column directory.
+ */
+ public static String file(final String directory, final String fileName) {
+ return directory + SEPARATOR + fileName;
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentReader.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentReader.java
new file mode 100644
index 0000000000..bcd7adcae2
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentReader.java
@@ -0,0 +1,239 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.FileChannel;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Objects;
+
+/**
+ * Reads the values of a finished segment sequentially, through a small buffer. The header is validated when the
+ * reader is opened, including that the file length matches it. Typed reads must match the segment's value width, as
+ * for {@link SegmentWriter}. The reader can be repositioned with {@link #seek(long)}.
+ * <p/>
+ * All I/O failures are reported as {@link UncheckedIOException}. Instances are not thread-safe.
+ */
+public final class SegmentReader implements AutoCloseable {
+
+ /**
+ * The default size of the read buffer in bytes.
+ */
+ public static final int DEFAULT_BUFFER_BYTES = 64 * 1024;
+
+ private final Path path;
+ private final FileChannel channel;
+ private final SegmentHeader header;
+ private final ByteBuffer buffer;
+ private final long endPosition;
+ // file position of the next byte to read from the channel into the buffer
+ private long channelPosition = SegmentHeader.SIZE;
+
+ private SegmentReader(final Path path, final int bufferBytes) {
+ if (bufferBytes < 16) throw new IllegalArgumentException("Buffer too small: " + bufferBytes);
+ this.path = path;
+ this.buffer = ByteBuffer.allocateDirect(bufferBytes).order(ByteOrder.LITTLE_ENDIAN);
+ this.buffer.limit(0);
+ try {
+ this.channel = FileChannel.open(path, StandardOpenOption.READ);
+ try {
+ this.header = SegmentHeader.read(channel, path);
+ } catch (RuntimeException | IOException e) {
+ channel.close();
+ throw e;
+ }
+ this.endPosition = header.fileBytes();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Opens a finished segment for sequential reading.
+ *
+ * @throws UncheckedIOException if the file cannot be read or is not a well-formed segment
+ */
+ public static SegmentReader open(final Path path) {
+ return new SegmentReader(path, DEFAULT_BUFFER_BYTES);
+ }
+
+ /**
+ * As {@link #open(Path)} with an explicit read buffer size.
+ */
+ public static SegmentReader open(final Path path, final int bufferBytes) {
+ return new SegmentReader(path, bufferBytes);
+ }
+
+ public Path path() {
+ return path;
+ }
+
+ public SegmentHeader header() {
+ return header;
+ }
+
+ public int valueWidth() {
+ return header.valueWidth();
+ }
+
+ /**
+ * The total number of values in the segment.
+ */
+ public long count() {
+ return header.count();
+ }
+
+ /**
+ * The index of the next value to be read.
+ */
+ public long position() {
+ return (channelPosition - buffer.remaining() - SegmentHeader.SIZE) / header.valueWidth();
+ }
+
+ /**
+ * The number of values that remain to be read.
+ */
+ public long remaining() {
+ return header.count() - position();
+ }
+
+ public boolean hasRemaining() {
+ return remaining() > 0;
+ }
+
+ /**
+ * Repositions the reader so the next read returns the value at {@code valueIndex}. An index equal to
+ * {@link #count()} positions the reader at the end.
+ */
+ public void seek(final long valueIndex) {
+ if (valueIndex < 0 || valueIndex > header.count()) {
+ throw new IndexOutOfBoundsException("Index " + valueIndex + " out of range [0, " + header.count() + "]");
+ }
+ buffer.clear().limit(0);
+ channelPosition = SegmentHeader.SIZE + valueIndex * header.valueWidth();
+ }
+
+ /**
+ * @throws java.io.UncheckedIOException wrapping {@link EOFException} at the end of the segment
+ */
+ public byte readByte() {
+ requireWidth(1);
+ ensure(1);
+ return buffer.get();
+ }
+
+ public short readShort() {
+ requireWidth(2);
+ ensure(2);
+ return buffer.getShort();
+ }
+
+ public int readInt() {
+ requireWidth(4);
+ ensure(4);
+ return buffer.getInt();
+ }
+
+ public long readLong() {
+ requireWidth(8);
+ ensure(8);
+ return buffer.getLong();
+ }
+
+ /**
+ * Reads raw payload bytes. The length must be a multiple of the value width for the reader to remain aligned.
+ */
+ public void readBytes(final byte[] dst, final int offset, final int length) {
+ Objects.checkFromIndexSize(offset, length, dst.length);
+ int off = offset;
+ int remaining = length;
+ while (remaining > 0) {
+ ensure(1);
+ final int n = Math.min(remaining, buffer.remaining());
+ buffer.get(dst, off, n);
+ off += n;
+ remaining -= n;
+ }
+ }
+
+ public void readBytes(final byte[] dst) {
+ readBytes(dst, 0, dst.length);
+ }
+
+ /**
+ * Recomputes the CRC32C of the payload and compares it with the header, without disturbing the read position.
+ *
+ * @throws UncheckedIOException if the checksum does not match
+ */
+ public void verifyChecksum() {
+ try {
+ final long actual = SegmentHeader.computeChecksum(channel, header.payloadBytes());
+ if (actual != header.checksum()) {
+ throw SegmentHeader.corrupt(path, "checksum mismatch, header " + header.checksum() + " computed " + actual);
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ @Override
+ public void close() {
+ try {
+ channel.close();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private void requireWidth(final int width) {
+ if (header.valueWidth() != width) {
+ throw new IllegalStateException("Segment " + path + " has value width " + header.valueWidth() + ", not " + width);
+ }
+ }
+
+ // makes at least n bytes available in the buffer
+ private void ensure(final int n) {
+ if (buffer.remaining() >= n) return;
+ buffer.compact();
+ try {
+ while (buffer.position() < n) {
+ final long left = endPosition - channelPosition;
+ if (left <= 0) {
+ buffer.flip();
+ throw new UncheckedIOException(new EOFException("End of segment " + path));
+ }
+ if (left < buffer.remaining()) buffer.limit((int) (buffer.position() + left));
+ final int read = channel.read(buffer, channelPosition);
+ if (read < 0) {
+ buffer.flip();
+ throw new UncheckedIOException(new EOFException("Unexpected end of file " + path));
+ }
+ channelPosition += read;
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ buffer.limit(buffer.position()).position(0);
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentWriter.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentWriter.java
new file mode 100644
index 0000000000..3071cf894c
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SegmentWriter.java
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.FileChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.zip.CRC32C;
+
+/**
+ * Appends values to a new segment file sequentially, through a small buffer. The header is reserved as 64 zero bytes
+ * when the writer is created and is filled in, including the count and the CRC32C checksum, only by {@link #finish()}
+ * or {@link #close()}. A segment that was never finished therefore fails the magic check when opened.
+ * <p/>
+ * Typed writes must match the segment's value width: {@link #writeByte} for width 1, {@link #writeShort} for 2,
+ * {@link #writeInt} for 4, {@link #writeLong} for 8. Width-16 segments and raw payloads use {@link #writeBytes}.
+ * Values are little-endian. Variable-width payloads such as {@code data.bin} are width-1 segments.
+ * <p/>
+ * All I/O failures are reported as {@link UncheckedIOException}. Instances are not thread-safe.
+ */
+public final class SegmentWriter implements AutoCloseable {
+
+ /**
+ * The default size of the write buffer in bytes.
+ */
+ public static final int DEFAULT_BUFFER_BYTES = 64 * 1024;
+
+ private final Path path;
+ private final int valueWidth;
+ private final FileChannel channel;
+ private final ByteBuffer buffer;
+ private final CRC32C crc = new CRC32C();
+ private long payloadBytes;
+ private long channelPosition = SegmentHeader.SIZE;
+ private SegmentHeader header;
+
+ private SegmentWriter(final Path path, final int valueWidth, final int bufferBytes) {
+ if (!SegmentHeader.isValidWidth(valueWidth)) throw new IllegalArgumentException("Invalid value width: " + valueWidth);
+ if (bufferBytes < 16) throw new IllegalArgumentException("Buffer too small: " + bufferBytes);
+ this.path = path;
+ this.valueWidth = valueWidth;
+ this.buffer = ByteBuffer.allocateDirect(bufferBytes).order(ByteOrder.LITTLE_ENDIAN);
+ try {
+ final Path parent = path.toAbsolutePath().getParent();
+ if (parent != null) Files.createDirectories(parent);
+ this.channel = FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
+ StandardOpenOption.TRUNCATE_EXISTING);
+ // reserve the header; it is all zero, and so invalid, until finish()
+ channel.write(ByteBuffer.allocate(SegmentHeader.SIZE), 0);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * Creates (or truncates) the segment file at {@code path}, creating missing parent directories.
+ *
+ * @param valueWidth the value width in bytes: 1, 2, 4, 8 or 16
+ */
+ public static SegmentWriter create(final Path path, final int valueWidth) {
+ return new SegmentWriter(path, valueWidth, DEFAULT_BUFFER_BYTES);
+ }
+
+ /**
+ * As {@link #create(Path, int)} with an explicit write buffer size.
+ */
+ public static SegmentWriter create(final Path path, final int valueWidth, final int bufferBytes) {
+ return new SegmentWriter(path, valueWidth, bufferBytes);
+ }
+
+ public Path path() {
+ return path;
+ }
+
+ public int valueWidth() {
+ return valueWidth;
+ }
+
+ /**
+ * The number of whole values written so far, which is the index the next value will have.
+ */
+ public long count() {
+ return payloadBytes / valueWidth;
+ }
+
+ public void writeByte(final int value) {
+ requireWidth(1);
+ room(1);
+ buffer.put((byte) value);
+ payloadBytes += 1;
+ }
+
+ public void writeShort(final int value) {
+ requireWidth(2);
+ room(2);
+ buffer.putShort((short) value);
+ payloadBytes += 2;
+ }
+
+ public void writeInt(final int value) {
+ requireWidth(4);
+ room(4);
+ buffer.putInt(value);
+ payloadBytes += 4;
+ }
+
+ public void writeLong(final long value) {
+ requireWidth(8);
+ room(8);
+ buffer.putLong(value);
+ payloadBytes += 8;
+ }
+
+ /**
+ * Appends raw payload bytes. The total number of payload bytes must be a multiple of the value width by the time
+ * the segment is finished.
+ */
+ public void writeBytes(final byte[] src) {
+ writeBytes(src, 0, src.length);
+ }
+
+ /**
+ * Appends raw payload bytes. The total number of payload bytes must be a multiple of the value width by the time
+ * the segment is finished.
+ */
+ public void writeBytes(final byte[] src, final int offset, final int length) {
+ java.util.Objects.checkFromIndexSize(offset, length, src.length);
+ int off = offset;
+ int remaining = length;
+ while (remaining > 0) {
+ if (!buffer.hasRemaining()) flushBuffer();
+ final int n = Math.min(remaining, buffer.remaining());
+ buffer.put(src, off, n);
+ off += n;
+ remaining -= n;
+ }
+ payloadBytes += length;
+ }
+
+ /**
+ * Writes the header, including the checksum, and closes the file. Idempotent.
+ *
+ * @return the finalized header
+ * @throws IllegalStateException if the payload length is not a multiple of the value width
+ */
+ public SegmentHeader finish() {
+ if (header != null) return header;
+ try {
+ flushBuffer();
+ if (payloadBytes % valueWidth != 0) {
+ channel.close();
+ throw new IllegalStateException("Segment " + path + " has " + payloadBytes
+ + " payload bytes, not a multiple of value width " + valueWidth);
+ }
+ header = new SegmentHeader(valueWidth, payloadBytes / valueWidth, crc.getValue());
+ final ByteBuffer h = header.toBuffer();
+ while (h.hasRemaining()) channel.write(h, h.position());
+ channel.close();
+ return header;
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ /**
+ * The finalized header. Valid only after {@link #finish()} or {@link #close()}.
+ */
+ public SegmentHeader header() {
+ if (header == null) throw new IllegalStateException("Segment " + path + " is not finished");
+ return header;
+ }
+
+ /**
+ * The checksum of the payload. Valid only after {@link #finish()} or {@link #close()}.
+ */
+ public long checksum() {
+ return header().checksum();
+ }
+
+ /**
+ * Describes the finished segment for the manifest. Valid only after {@link #finish()} or {@link #close()}.
+ *
+ * @param relativePath the path relative to the bundle root, using {@code '/'} separators
+ */
+ public Manifest.SegmentInfo info(final String relativePath) {
+ final SegmentHeader h = header();
+ return new Manifest.SegmentInfo(relativePath, h.valueWidth(), h.count(), h.checksum());
+ }
+
+ /**
+ * Finishes the segment if {@link #finish()} has not been called.
+ */
+ @Override
+ public void close() {
+ finish();
+ }
+
+ private void requireWidth(final int width) {
+ if (valueWidth != width) {
+ throw new IllegalStateException("Segment " + path + " has value width " + valueWidth + ", not " + width);
+ }
+ }
+
+ private void room(final int bytes) {
+ if (buffer.remaining() < bytes) flushBuffer();
+ }
+
+ private void flushBuffer() {
+ if (buffer.position() == 0) return;
+ buffer.flip();
+ crc.update(buffer.duplicate());
+ try {
+ while (buffer.hasRemaining()) channelPosition += channel.write(buffer, channelPosition);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ buffer.clear();
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SnapshotLayout.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SnapshotLayout.java
new file mode 100644
index 0000000000..924aaa105d
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/SnapshotLayout.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+/**
+ * Selects which segments are published. Builders always produce the structures they need internally; anything the
+ * layout does not publish is written under scratch and deleted before publishing.
+ */
+public enum SnapshotLayout {
+
+ /**
+ * Adjacency offsets and neighbors in both directions.
+ */
+ TOPOLOGY,
+
+ /**
+ * Adds vertex and edge identifiers, labels, identifier indexes, edge endpoints, and adjacency edge ordinals. The
+ * edge identifier index can be omitted with {@code BuildOptions.edgeIdIndex}.
+ */
+ IDENTITY,
+
+ /**
+ * Adds the property columns, including the vertex-property identifier columns.
+ */
+ FULL
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ValueCodec.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ValueCodec.java
new file mode 100644
index 0000000000..2c2ae39916
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ValueCodec.java
@@ -0,0 +1,534 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.UnsupportedSnapshotDataException;
+
+import java.io.ByteArrayOutputStream;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.VarHandle;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Encodes and decodes the value types a snapshot can store, following the canonical encodings of
+ * {@link ValueType}. All multi-byte numbers are little-endian, like the segments. Equality of encoded bytes together
+ * with the {@link ValueType} defines value (and so identifier) equality inside a snapshot.
+ * <p/>
+ * The supported Java classes are exactly {@link Boolean}, {@link Byte}, {@link Short}, {@link Character},
+ * {@link Integer}, {@link Float}, {@link Long}, {@link Double}, {@link String}, {@link UUID}, {@link BigInteger},
+ * {@link BigDecimal}, {@link Duration} and {@link OffsetDateTime} (the scalar types), {@code byte[]}, and any
+ * {@link List}, {@link Set} or {@link Map} whose entries are themselves supported or null. Subclasses of the scalar
+ * classes and every other type, including a top-level {@code null}, are rejected with
+ * {@link UnsupportedSnapshotDataException}. A string that is not well-formed UTF-16
+ * (an unpaired surrogate) is rejected as well, because it cannot be represented in UTF-8 without loss.
+ * <p/>
+ * Fixed-width types can also be handled as a {@code long} of "bits", with no allocation: the value for integral types,
+ * the raw IEEE bits for {@code FLOAT} and {@code DOUBLE}, 0 or 1 for {@code BOOLEAN}, and the unsigned value for
+ * {@code CHAR}. Only the low {@link ValueType#width()} bytes of the bits are significant. The encoded form of a
+ * fixed-width value is those bytes in little-endian order.
+ * <p/>
+ * A collection is an unsigned LEB128 varint entry count followed by the entries. An entry is its {@link ValueType}
+ * code byte, an unsigned LEB128 varint payload length and the payload, except that a {@code null} entry is only the
+ * {@link ValueType#NULL} code byte. A {@code LIST} keeps list order. {@code SET} entries and {@code MAP} entries
+ * (ordered by key, each key entry followed by its value entry) are written in canonical order: by type code and then by
+ * payload bytes, both compared as unsigned bytes. Decoding gives an {@link ArrayList}, a {@link LinkedHashSet} in
+ * stored order and a {@link LinkedHashMap}. Collections may be nested. Identifiers must be scalar, see
+ * {@link #requireIdentifierType}.
+ */
+public final class ValueCodec {
+
+ private static final VarHandle SHORT = MethodHandles.byteArrayViewVarHandle(short[].class, ByteOrder.LITTLE_ENDIAN);
+ private static final VarHandle INT = MethodHandles.byteArrayViewVarHandle(int[].class, ByteOrder.LITTLE_ENDIAN);
+ private static final VarHandle LONG = MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN);
+
+ private static final ValueType[] BY_CODE = new ValueType[256];
+
+ static {
+ for (final ValueType t : ValueType.values()) BY_CODE[t.code() & 0xff] = t;
+ }
+
+ private ValueCodec() {
+ }
+
+ /**
+ * Resolves a type from its on-disk code without throwing, in constant time.
+ *
+ * @return the type, or null if the code is 0 or unknown
+ */
+ public static ValueType typeOfCode(final byte code) {
+ return BY_CODE[code & 0xff];
+ }
+
+ /**
+ * The type of a value, or null if the value is null or of an unsupported class. Does not allocate.
+ */
+ public static ValueType typeOf(final Object value) {
+ if (value == null) return null;
+ final Class<?> c = value.getClass();
+ if (c == String.class) return ValueType.STRING;
+ if (c == Long.class) return ValueType.LONG;
+ if (c == Integer.class) return ValueType.INT;
+ if (c == Double.class) return ValueType.DOUBLE;
+ if (c == Boolean.class) return ValueType.BOOLEAN;
+ if (c == Float.class) return ValueType.FLOAT;
+ if (c == Short.class) return ValueType.SHORT;
+ if (c == Byte.class) return ValueType.BYTE;
+ if (c == Character.class) return ValueType.CHAR;
+ if (c == UUID.class) return ValueType.UUID;
+ if (c == BigInteger.class) return ValueType.BIGINT;
+ if (c == BigDecimal.class) return ValueType.BIGDECIMAL;
+ if (c == Duration.class) return ValueType.DURATION;
+ if (c == OffsetDateTime.class) return ValueType.DATETIME;
+ if (c == byte[].class) return ValueType.BINARY;
+ if (value instanceof List) return ValueType.LIST;
+ if (value instanceof Set) return ValueType.SET;
+ if (value instanceof Map) return ValueType.MAP;
+ return null;
+ }
+
+ /**
+ * The type of a value, rejecting null and unsupported classes loudly.
+ *
+ * @param kind what the value is, for the error message, for example {@code "vertex property"} or
+ * {@code "edge identifier"}
+ * @param key the property key for the error message, or null when the value is not a property
+ * @throws UnsupportedSnapshotDataException if the value is null or its class is not supported
+ */
+ public static ValueType requireType(final Object value, final String kind, final String key) {
+ final ValueType type = typeOf(value);
+ if (type == null) {
+ final String where = key == null ? kind : kind + " '" + key + "'";
+ if (value == null) throw new UnsupportedSnapshotDataException("Null value for " + where + " is not supported");
+ throw new UnsupportedSnapshotDataException("Value of unsupported type " + value.getClass().getName()
+ + " for " + where);
+ }
+ return type;
+ }
+
+ /**
+ * The type of an identifier, rejecting null, unsupported classes and the non-scalar {@code BINARY}, {@code LIST},
+ * {@code SET} and {@code MAP} types loudly.
+ *
+ * @param kind what the identifier is, for the error message, for example {@code "vertex identifier"}
+ * @param key the property key for the error message, or null when the identifier is not a property's
+ * @throws UnsupportedSnapshotDataException if the identifier is null, unsupported or not scalar
+ */
+ public static ValueType requireIdentifierType(final Object id, final String kind, final String key) {
+ final ValueType type = requireType(id, kind, key);
+ if (!type.isScalar()) {
+ final String where = key == null ? kind : kind + " '" + key + "'";
+ throw new UnsupportedSnapshotDataException("Non-scalar " + type + " value for " + where
+ + " is not supported as an identifier");
+ }
+ return type;
+ }
+
+ /**
+ * Whether a fixed-width or variable-width type is one of the integral types whose numeric value is used directly as
+ * an identifier index key: {@code BYTE}, {@code SHORT}, {@code INT} or {@code LONG}.
+ */
+ public static boolean isIntegral(final ValueType type) {
+ return type == ValueType.BYTE || type == ValueType.SHORT || type == ValueType.INT || type == ValueType.LONG;
+ }
+
+ // ---------------------------------------------------------------- fixed-width values as bits
+
+ /**
+ * The bits of a fixed-width value, see the class description. The value must be of class matching {@code type}.
+ *
+ * @throws IllegalArgumentException if the type is not fixed-width
+ */
+ public static long fixedBits(final ValueType type, final Object value) {
+ switch (type) {
+ case BOOLEAN:
+ return ((Boolean) value) ? 1L : 0L;
+ case BYTE:
+ return (Byte) value;
+ case SHORT:
+ return (Short) value;
+ case CHAR:
+ return (Character) value;
+ case INT:
+ return (Integer) value;
+ case FLOAT:
+ return Float.floatToRawIntBits((Float) value);
+ case LONG:
+ return (Long) value;
+ case DOUBLE:
+ return Double.doubleToRawLongBits((Double) value);
+ default:
+ throw new IllegalArgumentException("Not a fixed-width type: " + type);
+ }
+ }
+
+ /**
+ * The value of a fixed-width type from its bits.
+ *
+ * @throws IllegalArgumentException if the type is not fixed-width
+ */
+ public static Object fixedValue(final ValueType type, final long bits) {
+ switch (type) {
+ case BOOLEAN:
+ return (bits & 0xff) != 0;
+ case BYTE:
+ return (byte) bits;
+ case SHORT:
+ return (short) bits;
+ case CHAR:
+ return (char) bits;
+ case INT:
+ return (int) bits;
+ case FLOAT:
+ return Float.intBitsToFloat((int) bits);
+ case LONG:
+ return bits;
+ case DOUBLE:
+ return Double.longBitsToDouble(bits);
+ default:
+ throw new IllegalArgumentException("Not a fixed-width type: " + type);
+ }
+ }
+
+ /**
+ * The bits of a fixed-width value from its encoded bytes.
+ *
+ * @throws IllegalArgumentException if the type is not fixed-width or {@code length} is not its width
+ */
+ public static long fixedBitsFromBytes(final ValueType type, final byte[] buf, final int offset, final int length) {
+ if (!type.isFixedWidth() || length != type.width()) {
+ throw new IllegalArgumentException("Cannot read " + length + " bytes as " + type);
+ }
+ switch (type.width()) {
+ case 1:
+ return buf[offset];
+ case 2:
+ return (short) SHORT.get(buf, offset);
+ case 4:
+ return (int) INT.get(buf, offset);
+ default:
+ return (long) LONG.get(buf, offset);
+ }
+ }
+
+ /**
+ * The encoded bytes of a fixed-width value given its bits.
+ *
+ * @throws IllegalArgumentException if the type is not fixed-width
+ */
+ public static byte[] fixedBitsToBytes(final ValueType type, final long bits) {
+ if (!type.isFixedWidth()) throw new IllegalArgumentException("Not a fixed-width type: " + type);
+ final byte[] out = new byte[type.width()];
+ switch (type.width()) {
+ case 1:
+ out[0] = (byte) bits;
+ break;
+ case 2:
+ SHORT.set(out, 0, (short) bits);
+ break;
+ case 4:
+ INT.set(out, 0, (int) bits);
+ break;
+ default:
+ LONG.set(out, 0, bits);
+ break;
+ }
+ return out;
+ }
+
+ // ---------------------------------------------------------------- general encode / decode
+
+ /**
+ * Encodes a value of the given type into its canonical bytes. For fixed-width types this is
+ * {@link #fixedBitsToBytes}. The {@code type} must be the value's {@link #typeOf type}.
+ *
+ * @throws UnsupportedSnapshotDataException if a string is not well-formed UTF-16
+ */
+ public static byte[] encode(final ValueType type, final Object value) {
+ switch (type) {
+ case STRING: {
+ final String s = (String) value;
+ checkWellFormed(s);
+ return s.getBytes(StandardCharsets.UTF_8);
+ }
+ case UUID: {
+ final UUID u = (UUID) value;
+ final byte[] out = new byte[16];
+ LONG.set(out, 0, u.getMostSignificantBits());
+ LONG.set(out, 8, u.getLeastSignificantBits());
+ return out;
+ }
+ case BIGINT:
+ return ((BigInteger) value).toByteArray();
+ case BIGDECIMAL: {
+ final BigDecimal d = (BigDecimal) value;
+ final byte[] unscaled = d.unscaledValue().toByteArray();
+ final byte[] out = new byte[4 + unscaled.length];
+ INT.set(out, 0, d.scale());
+ System.arraycopy(unscaled, 0, out, 4, unscaled.length);
+ return out;
+ }
+ case DURATION: {
+ final Duration d = (Duration) value;
+ final byte[] out = new byte[12];
+ LONG.set(out, 0, d.getSeconds());
+ INT.set(out, 8, d.getNano());
+ return out;
+ }
+ case DATETIME: {
+ final OffsetDateTime t = (OffsetDateTime) value;
+ final byte[] out = new byte[16];
+ LONG.set(out, 0, t.toEpochSecond());
+ INT.set(out, 8, t.getNano());
+ INT.set(out, 12, t.getOffset().getTotalSeconds());
+ return out;
+ }
+ case BINARY:
+ return ((byte[]) value).clone();
+ case LIST: {
+ final List<?> list = (List<?>) value;
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ putVarint(out, list.size());
+ for (final Object e : list) Entry.of(e).write(out);
+ return out.toByteArray();
+ }
+ case SET: {
+ final Set<?> set = (Set<?>) value;
+ final List<Entry> entries = new ArrayList<>(set.size());
+ for (final Object e : set) entries.add(Entry.of(e));
+ entries.sort(null);
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ putVarint(out, entries.size());
+ for (final Entry e : entries) e.write(out);
+ return out.toByteArray();
+ }
+ case MAP: {
+ final Map<?, ?> map = (Map<?, ?>) value;
+ final List<Entry[]> pairs = new ArrayList<>(map.size());
+ for (final Map.Entry<?, ?> e : map.entrySet()) {
+ pairs.add(new Entry[]{Entry.of(e.getKey()), Entry.of(e.getValue())});
+ }
+ pairs.sort((a, b) -> a[0].compareTo(b[0]));
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ putVarint(out, pairs.size());
+ for (final Entry[] pair : pairs) {
+ pair[0].write(out);
+ pair[1].write(out);
+ }
+ return out.toByteArray();
+ }
+ case NULL:
+ throw new IllegalArgumentException("NULL has no encoding outside a collection");
+ default:
+ return fixedBitsToBytes(type, fixedBits(type, value));
+ }
+ }
+
+ /**
+ * Determines the type of a value and encodes it.
+ *
+ * @throws UnsupportedSnapshotDataException if the value is null, of an unsupported class, or an ill-formed string
+ */
+ public static byte[] encode(final Object value) {
+ return encode(requireType(value, "value", null), value);
+ }
+
+ /**
+ * Decodes {@code length} bytes at {@code offset} as a value of the given type. This is the inverse of
+ * {@link #encode(ValueType, Object)}.
+ *
+ * @throws IllegalArgumentException if the length is impossible for the type
+ */
+ public static Object decode(final ValueType type, final byte[] buf, final int offset, final int length) {
+ switch (type) {
+ case STRING:
+ return new String(buf, offset, length, StandardCharsets.UTF_8);
+ case UUID:
+ requireLength(type, length, 16);
+ return new UUID((long) LONG.get(buf, offset), (long) LONG.get(buf, offset + 8));
+ case BIGINT:
+ if (length < 1) throw new IllegalArgumentException("Cannot decode an empty BIGINT");
+ return new BigInteger(buf, offset, length);
+ case BIGDECIMAL:
+ if (length < 5) throw new IllegalArgumentException("Cannot decode BIGDECIMAL of " + length + " bytes");
+ return new BigDecimal(new BigInteger(buf, offset + 4, length - 4), (int) INT.get(buf, offset));
+ case DURATION:
+ requireLength(type, length, 12);
+ return Duration.ofSeconds((long) LONG.get(buf, offset), (int) INT.get(buf, offset + 8));
+ case DATETIME:
+ requireLength(type, length, 16);
+ return OffsetDateTime.ofInstant(Instant.ofEpochSecond((long) LONG.get(buf, offset),
+ (int) INT.get(buf, offset + 8)), ZoneOffset.ofTotalSeconds((int) INT.get(buf, offset + 12)));
+ case BINARY:
+ return Arrays.copyOfRange(buf, offset, offset + length);
+ case LIST: {
+ final int[] pos = {offset};
+ final int end = offset + length;
+ final long count = readVarint(buf, pos, end);
+ final List<Object> out = new ArrayList<>((int) Math.min(count, 1024));
+ for (long i = 0; i < count; i++) out.add(readEntry(buf, pos, end));
+ requireConsumed(type, pos[0], end);
+ return out;
+ }
+ case SET: {
+ final int[] pos = {offset};
+ final int end = offset + length;
+ final long count = readVarint(buf, pos, end);
+ final Set<Object> out = new LinkedHashSet<>();
+ for (long i = 0; i < count; i++) out.add(readEntry(buf, pos, end));
+ requireConsumed(type, pos[0], end);
+ return out;
+ }
+ case MAP: {
+ final int[] pos = {offset};
+ final int end = offset + length;
+ final long count = readVarint(buf, pos, end);
+ final Map<Object, Object> out = new LinkedHashMap<>();
+ for (long i = 0; i < count; i++) {
+ final Object k = readEntry(buf, pos, end);
+ out.put(k, readEntry(buf, pos, end));
+ }
+ requireConsumed(type, pos[0], end);
+ return out;
+ }
+ case NULL:
+ throw new IllegalArgumentException("NULL has no encoding outside a collection");
+ default:
+ return fixedValue(type, fixedBitsFromBytes(type, buf, offset, length));
+ }
+ }
+
+ /**
+ * Decodes a whole array, see {@link #decode(ValueType, byte[], int, int)}.
+ */
+ public static Object decode(final ValueType type, final byte[] encoded) {
+ return decode(type, encoded, 0, encoded.length);
+ }
+
+ // ---------------------------------------------------------------- collections
+
+ /**
+ * Appends an unsigned LEB128 varint.
+ */
+ private static void putVarint(final ByteArrayOutputStream out, final long value) {
+ long v = value;
+ while ((v & ~0x7fL) != 0) {
+ out.write((int) ((v & 0x7f) | 0x80));
+ v >>>= 7;
+ }
+ out.write((int) v);
+ }
+
+ private static long readVarint(final byte[] buf, final int[] pos, final int end) {
+ long result = 0;
+ int shift = 0;
+ while (true) {
+ if (pos[0] >= end || shift > 63) throw new IllegalArgumentException("Malformed varint in collection");
+ final int b = buf[pos[0]++];
+ result |= (long) (b & 0x7f) << shift;
+ if ((b & 0x80) == 0) return result;
+ shift += 7;
+ }
+ }
+
+ // reads the entry at pos[0] and advances pos
+ private static Object readEntry(final byte[] buf, final int[] pos, final int end) {
+ if (pos[0] >= end) throw new IllegalArgumentException("Truncated collection entry");
+ final ValueType type = typeOfCode(buf[pos[0]++]);
+ if (type == null) throw new IllegalArgumentException("Invalid type code in collection entry");
+ if (type == ValueType.NULL) return null;
+ final long length = readVarint(buf, pos, end);
+ if (length > end - pos[0]) throw new IllegalArgumentException("Truncated collection entry payload");
+ final Object value = decode(type, buf, pos[0], (int) length);
+ pos[0] += (int) length;
+ return value;
+ }
+
+ private static void requireConsumed(final ValueType type, final int position, final int end) {
+ if (position != end) {
+ throw new IllegalArgumentException("Cannot decode " + type + ": " + (end - position) + " trailing bytes");
+ }
+ }
+
+ // one collection entry in encoded form, ordered canonically: by type code and then by payload, unsigned
+ private static final class Entry implements Comparable<Entry> {
+ private final ValueType type;
+ private final byte[] payload;
+
+ private Entry(final ValueType type, final byte[] payload) {
+ this.type = type;
+ this.payload = payload;
+ }
+
+ static Entry of(final Object value) {
+ if (value == null) return new Entry(ValueType.NULL, new byte[0]);
+ final ValueType type = requireType(value, "collection entry", null);
+ return new Entry(type, encode(type, value));
+ }
+
+ void write(final ByteArrayOutputStream out) {
+ out.write(type.code());
+ if (type == ValueType.NULL) return;
+ putVarint(out, payload.length);
+ out.write(payload, 0, payload.length);
+ }
+
+ @Override
+ public int compareTo(final Entry other) {
+ final int c = Integer.compare(type.code() & 0xff, other.type.code() & 0xff);
+ return c != 0 ? c : Arrays.compareUnsigned(payload, other.payload);
+ }
+ }
+
+ private static void requireLength(final ValueType type, final int actual, final int expected) {
+ if (actual != expected) {
+ throw new IllegalArgumentException("Cannot decode " + type + " from " + actual + " bytes, expected " + expected);
+ }
+ }
+
+ // rejects unpaired surrogates, which String.getBytes would silently replace with '?'
+ private static void checkWellFormed(final String s) {
+ final int n = s.length();
+ for (int i = 0; i < n; i++) {
+ final char c = s.charAt(i);
+ if (Character.isSurrogate(c)) {
+ if (Character.isHighSurrogate(c) && i + 1 < n && Character.isLowSurrogate(s.charAt(i + 1))) {
+ i++;
+ } else {
+ throw new UnsupportedSnapshotDataException("String contains an unpaired surrogate at index " + i
+ + " and cannot be encoded as UTF-8");
+ }
+ }
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ValueType.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ValueType.java
new file mode 100644
index 0000000000..e89ddef89e
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/ValueType.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+/**
+ * The value types a snapshot can store, each with a stable one-byte code and a canonical encoding. This enum is owned
+ * by the snapshot format and deliberately does not reuse {@code GType} ordinals. Codes must never change or be reused.
+ */
+public enum ValueType {
+
+ BOOLEAN(1, 1),
+ BYTE(2, 1),
+ SHORT(3, 2),
+ CHAR(4, 2),
+ INT(5, 4),
+ FLOAT(6, 4),
+ LONG(7, 8),
+ DOUBLE(8, 8),
+ STRING(9, ValueType.VARIABLE),
+ UUID(10, ValueType.VARIABLE),
+ BIGINT(11, ValueType.VARIABLE),
+ BIGDECIMAL(12, ValueType.VARIABLE),
+ DURATION(13, ValueType.VARIABLE),
+ DATETIME(14, ValueType.VARIABLE),
+ BINARY(15, ValueType.VARIABLE),
+ LIST(16, ValueType.VARIABLE),
+ SET(17, ValueType.VARIABLE),
+ MAP(18, ValueType.VARIABLE),
+ /**
+ * Reserved for a null entry inside a collection. It has no payload and never appears outside a collection, so it is
+ * never a column value type.
+ */
+ NULL(19, ValueType.VARIABLE);
+
+ /**
+ * The width reported by {@link #width()} for variable-width types.
+ */
+ public static final int VARIABLE = -1;
+
+ private final byte code;
+ private final int width;
+
+ ValueType(final int code, final int width) {
+ this.code = (byte) code;
+ this.width = width;
+ }
+
+ /**
+ * The stable one-byte code written to disk.
+ */
+ public byte code() {
+ return code;
+ }
+
+ /**
+ * The encoded width in bytes, or {@link #VARIABLE} for variable-width types.
+ */
+ public int width() {
+ return width;
+ }
+
+ public boolean isFixedWidth() {
+ return width != VARIABLE;
+ }
+
+ /**
+ * Whether this type can be an identifier: every type except {@link #BINARY}, {@link #LIST}, {@link #SET},
+ * {@link #MAP} and {@link #NULL}.
+ */
+ public boolean isScalar() {
+ return code <= DATETIME.code;
+ }
+
+ /**
+ * Whether this type is a {@link #LIST}, {@link #SET} or {@link #MAP}.
+ */
+ public boolean isCollection() {
+ return this == LIST || this == SET || this == MAP;
+ }
+
+ /**
+ * Resolves a type from its on-disk code.
+ *
+ * @throws IllegalArgumentException if the code is unknown
+ */
+ public static ValueType fromCode(final byte code) {
+ for (final ValueType t : values()) {
+ if (t.code == code) return t;
+ }
+ throw new IllegalArgumentException("Unknown value type code: " + code);
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/VertexPropertyColumnWriter.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/VertexPropertyColumnWriter.java
new file mode 100644
index 0000000000..bc7f974a5e
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/format/VertexPropertyColumnWriter.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.format;
+
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.UnsupportedSnapshotDataException;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Writes the column of one vertex property key together with its vertex-property identifier column, in one pass. The
+ * value column is written to {@link SegmentPaths#vertexPropertyDir(int)} and the identifier column, which has the same
+ * entries in the same positions, to {@link SegmentPaths#vertexPropertyIdsDir(int)} as a companion that reuses the
+ * parent's {@code presence.bin} or {@code ordinals.bin}.
+ * <p/>
+ * Both {@link Manifest.ColumnInfo}s must come from statistics over the same entries, so they have the same present
+ * count and therefore the same density. The identifier types are usually different from the value types. The value
+ * column may contain nulls, see {@link ColumnWriter#appendNull}, but identifiers never are null.
+ * <p/>
+ * The same writer serves both vertex property layouts. In the single layout the element count is the vertex count and
+ * the ordinal of an append is a vertex ordinal. In the multi layout the element count is the key's vertex-property
+ * count, every ordinal from 0 is appended so both columns are dense and complete and write no {@code presence.bin},
+ * and the ordinal of an append is the vertex-property ordinal.
+ */
+public final class VertexPropertyColumnWriter implements AutoCloseable {
+
+ private final ColumnWriter values;
+ private final ColumnWriter ids;
+ private List<Manifest.SegmentInfo> segments;
+
+ private VertexPropertyColumnWriter(final ColumnWriter values, final ColumnWriter ids) {
+ this.values = values;
+ this.ids = ids;
+ }
+
+ /**
+ * Creates the writer for one vertex property key.
+ *
+ * @param resolver maps a path relative to the bundle root (or the scratch root) to a file, for example
+ * {@code BuildDirectory::segmentPath}
+ * @param keyCode the property-key code
+ * @param elementCount the number of vertices
+ * @param valueInfo the value column's info
+ * @param idInfo the identifier column's info, with the same present count as {@code valueInfo}
+ */
+ public static VertexPropertyColumnWriter create(final Function<String, Path> resolver, final int keyCode,
+ final long elementCount, final Manifest.ColumnInfo valueInfo,
+ final Manifest.ColumnInfo idInfo) {
+ if (valueInfo.presentCount() != idInfo.presentCount()) {
+ throw new IllegalArgumentException("Vertex property key " + keyCode + " has " + valueInfo.presentCount()
+ + " values but " + idInfo.presentCount() + " identifiers");
+ }
+ final ColumnWriter v = ColumnWriter.create(resolver, SegmentPaths.vertexPropertyDir(keyCode), elementCount,
+ valueInfo);
+ try {
+ final ColumnWriter i = ColumnWriter.createCompanion(resolver, SegmentPaths.vertexPropertyIdsDir(keyCode),
+ elementCount, idInfo);
+ return new VertexPropertyColumnWriter(v, i);
+ } catch (RuntimeException e) {
+ v.close();
+ throw e;
+ }
+ }
+
+ public Manifest.ColumnInfo valueInfo() {
+ return values.info();
+ }
+
+ public Manifest.ColumnInfo idInfo() {
+ return ids.info();
+ }
+
+ /**
+ * Appends a vertex property.
+ *
+ * @param ordinal the vertex ordinal, greater than the previous one
+ * @param id the vertex-property identifier
+ * @param value the property value, which may be null
+ * @throws UnsupportedSnapshotDataException if the identifier is null or either is of an unsupported class
+ */
+ public void append(final long ordinal, final Object id, final Object value) {
+ ids.append(ordinal, id);
+ values.append(ordinal, value);
+ }
+
+ /**
+ * Appends an already encoded vertex property, for example one replayed from a spool. See
+ * {@link ColumnWriter#appendEncoded}.
+ */
+ public void appendEncoded(final long ordinal,
+ final ValueType idType, final byte[] idBuf, final int idOffset, final int idLength,
+ final ValueType valueType, final byte[] valueBuf, final int valueOffset,
+ final int valueLength) {
+ ids.appendEncoded(ordinal, idType, idBuf, idOffset, idLength);
+ values.appendEncoded(ordinal, valueType, valueBuf, valueOffset, valueLength);
+ }
+
+ /**
+ * Appends an already encoded vertex property whose value is null.
+ */
+ public void appendNullValueEncoded(final long ordinal, final ValueType idType, final byte[] idBuf,
+ final int idOffset, final int idLength) {
+ ids.appendEncoded(ordinal, idType, idBuf, idOffset, idLength);
+ values.appendNull(ordinal);
+ }
+
+ /**
+ * Completes both columns.
+ *
+ * @return the segments of both columns, in ascending path order
+ */
+ public List<Manifest.SegmentInfo> finish() {
+ if (segments == null) {
+ final List<Manifest.SegmentInfo> all = new ArrayList<>(values.finish());
+ all.addAll(ids.finish());
+ all.sort(Comparator.comparing(Manifest.SegmentInfo::path));
+ segments = all;
+ }
+ return segments;
+ }
+
+ @Override
+ public void close() {
+ values.close();
+ ids.close();
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/graph/CsrEdge.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/graph/CsrEdge.java
new file mode 100644
index 0000000000..b698fc6190
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/graph/CsrEdge.java
@@ -0,0 +1,215 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.graph;
+
+import org.apache.tinkerpop.gremlin.structure.Direction;
+import org.apache.tinkerpop.gremlin.structure.Edge;
+import org.apache.tinkerpop.gremlin.structure.Element;
+import org.apache.tinkerpop.gremlin.structure.Graph;
+import org.apache.tinkerpop.gremlin.structure.Property;
+import org.apache.tinkerpop.gremlin.structure.Vertex;
+import org.apache.tinkerpop.gremlin.structure.snapshot.CsrSnapshot;
+import org.apache.tinkerpop.gremlin.structure.util.ElementHelper;
+import org.apache.tinkerpop.gremlin.structure.util.StringFactory;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+/**
+ * A read-only edge facade holding the graph and an edge ordinal. Facades are created on demand and never cached.
+ */
+public final class CsrEdge implements Edge, CsrElement {
+
+ private final CsrGraph graph;
+ private final int ordinal;
+ private Object id;
+
+ CsrEdge(final CsrGraph graph, final int ordinal) {
+ this.graph = graph;
+ this.ordinal = ordinal;
+ }
+
+ @Override
+ public int ordinal() {
+ return ordinal;
+ }
+
+ @Override
+ public Object id() {
+ Object result = id;
+ if (result == null) {
+ result = graph.snapshot().edgeId(ordinal);
+ id = result;
+ }
+ return result;
+ }
+
+ @Override
+ public String label() {
+ return graph.snapshot().edgeLabel(ordinal);
+ }
+
+ @Override
+ public CsrGraph graph() {
+ return graph;
+ }
+
+ @Override
+ public Vertex outVertex() {
+ return new CsrVertex(graph, graph.snapshot().edgeOut(ordinal));
+ }
+
+ @Override
+ public Vertex inVertex() {
+ return new CsrVertex(graph, graph.snapshot().edgeIn(ordinal));
+ }
+
+ @Override
+ public Iterator<Vertex> vertices(final Direction direction) {
+ final CsrSnapshot snapshot = graph.snapshot();
+ switch (direction) {
+ case OUT:
+ return new Pair(new CsrVertex(graph, snapshot.edgeOut(ordinal)), null);
+ case IN:
+ return new Pair(new CsrVertex(graph, snapshot.edgeIn(ordinal)), null);
+ default:
+ return new Pair(new CsrVertex(graph, snapshot.edgeOut(ordinal)),
+ new CsrVertex(graph, snapshot.edgeIn(ordinal)));
+ }
+ }
+
+ @Override
+ public <V> Property<V> property(final String key) {
+ final int code = graph.edgeKeyCode(key);
+ if (code < 0 || !graph.edgeColumn(code).isPresent(ordinal)) return Property.empty();
+ return new CsrProperty<>(graph, ordinal, code);
+ }
+
+ @Override
+ public <V> Property<V> property(final String key, final V value) {
+ throw Element.Exceptions.propertyAdditionNotSupported();
+ }
+
+ @Override
+ public <V> Iterator<Property<V>> properties(final String... propertyKeys) {
+ final int[] codes;
+ if (propertyKeys.length == 0) {
+ codes = null;
+ } else {
+ codes = new int[propertyKeys.length];
+ for (int i = 0; i < codes.length; i++) codes[i] = graph.edgeKeyCode(propertyKeys[i]);
+ }
+ return new PropertyIterator<>(graph, ordinal, codes);
+ }
+
+ @Override
+ public void remove() {
+ throw Edge.Exceptions.edgeRemovalNotSupported();
+ }
+
+ @Override
+ public boolean equals(final Object object) {
+ if (object instanceof CsrEdge) {
+ final CsrEdge other = (CsrEdge) object;
+ if (other.graph.snapshot() == graph.snapshot()) return other.ordinal == ordinal;
+ }
+ return ElementHelper.areEqual(this, object);
+ }
+
+ @Override
+ public int hashCode() {
+ return id().hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return StringFactory.edgeString(this);
+ }
+
+ /**
+ * Up to two vertices, in order.
+ */
+ private static final class Pair implements Iterator<Vertex> {
+ private Vertex first;
+ private Vertex second;
+
+ private Pair(final Vertex first, final Vertex second) {
+ this.first = first;
+ this.second = second;
+ }
+
+ @Override
+ public boolean hasNext() {
+ return first != null || second != null;
+ }
+
+ @Override
+ public Vertex next() {
+ if (first != null) {
+ final Vertex result = first;
+ first = null;
+ return result;
+ }
+ if (second != null) {
+ final Vertex result = second;
+ second = null;
+ return result;
+ }
+ throw new NoSuchElementException();
+ }
+ }
+
+ /**
+ * The present properties of the edge, for the given key codes (negative codes are skipped) or, when the codes are
+ * null, for all keys in key-code order.
+ */
+ private static final class PropertyIterator<V> implements Iterator<Property<V>> {
+ private final CsrGraph graph;
+ private final int edge;
+ private final int[] codes;
+ private final int limit;
+ private int index = 0;
+ private int pending = -1;
+
+ private PropertyIterator(final CsrGraph graph, final int edge, final int[] codes) {
+ this.graph = graph;
+ this.edge = edge;
+ this.codes = codes;
+ this.limit = codes == null ? graph.edgeKeyCount() : codes.length;
+ }
+
+ @Override
+ public boolean hasNext() {
+ while (pending < 0 && index < limit) {
+ final int code = codes == null ? index : codes[index];
+ index++;
+ if (code >= 0 && graph.edgeColumn(code).isPresent(edge)) pending = code;
+ }
+ return pending >= 0;
+ }
+
+ @Override
+ public Property<V> next() {
+ if (!hasNext()) throw new NoSuchElementException();
+ final int code = pending;
+ pending = -1;
+ return new CsrProperty<>(graph, edge, code);
+ }
+ }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/graph/CsrElement.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/graph/CsrElement.java
new file mode 100644
index 0000000000..07a54bdabe
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/graph/CsrElement.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.graph;
+
+import org.apache.tinkerpop.gremlin.structure.Element;
+
+/**
+ * An {@link Element} facade of a {@link CsrGraph}, which is a vertex or edge ordinal in the graph's snapshot. Native
+ * execution uses it to turn the elements of upstream traversers back into ordinals.
+ */
+public interface CsrElement extends Element {
+
+ /**
+ * The graph whose snapshot the ordinal belongs to.
+ */
+ @Override
+ CsrGraph graph();
+
+ /**
+ * The vertex ordinal of a {@link CsrVertex} or the edge ordinal of a {@link CsrEdge}.
+ */
+ int ordinal();
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/graph/CsrFeatures.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/graph/CsrFeatures.java
new file mode 100644
index 0000000000..98448fc85e
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/snapshot/graph/CsrFeatures.java
@@ -0,0 +1,487 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.snapshot.graph;
+
+import org.apache.tinkerpop.gremlin.structure.Graph;
+import org.apache.tinkerpop.gremlin.structure.LabelCardinality;
+import org.apache.tinkerpop.gremlin.structure.VertexProperty;
+import org.apache.tinkerpop.gremlin.structure.snapshot.CsrSnapshot;
+import org.apache.tinkerpop.gremlin.structure.snapshot.spi.SourceDefaults;
+
+import java.util.List;
+
+/**
+ * The read-only features of a {@link CsrGraph}. The supported data types are those of the snapshot's
+ * {@code ValueType}; of the types that {@link Graph.Features.DataTypeFeatures} can describe, these are booleans,
+ * bytes, doubles, floats, integers, longs, strings, maps, lists (uniform and mixed) and byte arrays. Other arrays and
+ * serializable values are not supported. Multi-properties, meta-properties, null property values and graph variables
+ * are supported for reading. Vertex label cardinality, the default vertex label and the cardinality of a property key
+ * come from the snapshot.
+ */
+public final class CsrFeatures implements Graph.Features {
+
+ private final GraphFeatures graphFeatures = new CsrGraphFeatures();
+ private final VertexFeatures vertexFeatures;
+ private final EdgeFeatures edgeFeatures = new CsrEdgeFeatures();
+
+ CsrFeatures(final CsrSnapshot snapshot) {
+ this.vertexFeatures = new CsrVertexFeatures(snapshot);
+ }
+
+ @Override
+ public GraphFeatures graph() {
+ return graphFeatures;
+ }
+
+ @Override
+ public VertexFeatures vertex() {
+ return vertexFeatures;
+ }
+
+ @Override
+ public EdgeFeatures edge() {
+ return edgeFeatures;
+ }
+
+ @Override
+ public String toString() {
+ return org.apache.tinkerpop.gremlin.structure.util.StringFactory.featureString(this);
+ }
+
+ private static final class CsrGraphFeatures implements GraphFeatures {
+ @Override
+ public boolean supportsComputer() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsPersistence() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsConcurrentAccess() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsTransactions() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsThreadedTransactions() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsIoWrite() {
+ return false;
+ }
+
+ @Override
+ public VariableFeatures variables() {
+ return new CsrVariableFeatures();
+ }
+ }
+
+ private static final class CsrVertexFeatures implements VertexFeatures {
+ private final VertexPropertyFeatures propertyFeatures = new CsrVertexPropertyFeatures();
+ private final CsrSnapshot snapshot;
+ private final List<String> keys;
+ private final SourceDefaults defaults;
+ private final VertexProperty.Cardinality defaultCardinality;
+ private final LabelCardinality labelCardinality;
+
+ private CsrVertexFeatures(final CsrSnapshot snapshot) {
+ this.snapshot = snapshot;
+ this.keys = snapshot.vertexPropertyKeys();
+ this.defaults = snapshot.sourceDefaults();
+ this.defaultCardinality = parseCardinality(defaults.defaultVertexPropertyCardinality());
+ this.labelCardinality = parseLabelCardinality(defaults.vertexLabelCardinality());
+ }
+
+ @Override
+ public VertexProperty.Cardinality getCardinality(final String key) {
+ final int code = keys.indexOf(key);
+ return code >= 0 && snapshot.isMultiProperty(code) ? VertexProperty.Cardinality.list : defaultCardinality;
+ }
+
+ @Override
+ public LabelCardinality getLabelCardinality() {
+ return labelCardinality;
+ }
+
+ @Override
+ public String getDefaultLabel() {
+ return defaults.defaultVertexLabel();
+ }
+
+ @Override
... 27348 lines suppressed ...