This is an automated email from the ASF dual-hosted git repository.

spmallette pushed a commit to branch tinkergraph-storage
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git


The following commit(s) were added to refs/heads/tinkergraph-storage by this 
push:
     new 08f6937de5 Restore TinkerStorageGraph index definitions on reopen
08f6937de5 is described below

commit 08f6937de529f74e8424935d474411087be77fac
Author: Stephen Mallette <[email protected]>
AuthorDate: Wed Sep 9 15:58:14 2026 -0400

    Restore TinkerStorageGraph index definitions on reopen
    
    An index created with createIndex() was not part of the durable state, so a
    graph that was reopened came back correct but with every indexed lookup
    silently degraded to a linear scan, with nothing to signal it. The indexed 
keys
    are now recorded beside the engine's files and the indexes are rebuilt after
    replay, covering data written before the restart. A dropped index stays
    dropped, and a record that cannot be read opens the graph with no indexes
    rather than failing.
    
    Definitions are kept outside the transaction log deliberately: an index only
    affects how fast a lookup runs and never its result, so a definition lost 
to a
    crash costs a rebuild rather than any data. The file is owned by the graph
    rather than the engine, so the TinkerStorage SPI is unchanged and a custom
    engine gets the behaviour for free.
    
    Assisted-by: Claude Code:claude-opus-5
    Claude-Session: https://claude.ai/code/session_01KgH2VCpRw57sbFg5GoAiVV
---
 .../reference/implementations-tinkergraph.asciidoc |   6 +
 .../tinkergraph/structure/TinkerStorageGraph.java  |  42 +++++
 .../structure/storage/IndexDefinitions.java        | 205 +++++++++++++++++++++
 .../structure/storage/GraphBinaryStorageTest.java  | 105 +++++++++++
 4 files changed, 358 insertions(+)

diff --git a/docs/src/reference/implementations-tinkergraph.asciidoc 
b/docs/src/reference/implementations-tinkergraph.asciidoc
index 0fc5ed45df..d4558a4ddc 100644
--- a/docs/src/reference/implementations-tinkergraph.asciidoc
+++ b/docs/src/reference/implementations-tinkergraph.asciidoc
@@ -468,6 +468,12 @@ committed state. Compaction runs when the graph is closed 
and can be requested e
 unbounded log, compaction also runs automatically once the log grows past 
`gremlin.tinkergraph.storage.compactThreshold`
 bytes. Setting that threshold to `0` disables automatic compaction.
 
+The property keys a graph indexes are recorded alongside its data and the 
indexes are rebuilt when the graph is
+opened again, so an index created with `createIndex()` survives a restart and 
covers the data that was already
+stored. An index dropped with `dropIndex()` stays dropped. Index definitions 
are held outside the transaction log
+because an index only affects how fast a lookup runs and never its result, so 
a definition lost to a crash costs a
+rebuild rather than any data. If the record of them cannot be read the graph 
still opens, with no indexes.
+
 Element and edge ids are always preserved across a reopen. Auto-generated 
vertex-property ids are not, by default,
 so a vertex property may receive a different id after a reopen. Setting 
`gremlin.tinkergraph.storage.preserveVertexPropertyIds`
 to `true` persists those ids as well, at the cost of a larger store.
diff --git 
a/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/TinkerStorageGraph.java
 
b/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/TinkerStorageGraph.java
index dc68a89ea5..43c61a9fce 100644
--- 
a/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/TinkerStorageGraph.java
+++ 
b/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/TinkerStorageGraph.java
@@ -36,6 +36,7 @@ import 
org.apache.tinkerpop.gremlin.tinkergraph.process.traversal.strategy.optim
 import 
org.apache.tinkerpop.gremlin.tinkergraph.process.traversal.strategy.optimization.TinkerGraphStepStrategy;
 import org.apache.tinkerpop.gremlin.tinkergraph.services.TinkerServiceRegistry;
 import 
org.apache.tinkerpop.gremlin.tinkergraph.structure.storage.DirectoryLock;
+import 
org.apache.tinkerpop.gremlin.tinkergraph.structure.storage.IndexDefinitions;
 import org.apache.tinkerpop.gremlin.util.iterator.IteratorUtils;
 
 import java.io.File;
@@ -84,6 +85,12 @@ public final class TinkerStorageGraph extends 
AbstractTinkerGraph {
 
     private final TinkerTransaction transaction = new TinkerTransaction(this);
 
+    /**
+     * Set while indexes recorded for the store are being recreated on open, 
so applying them does not rewrite the
+     * file they were just read from.
+     */
+    private boolean restoringIndexes = false;
+
     private final Map<Object, TinkerElementContainer<TinkerVertex>> vertices = 
new ConcurrentHashMap<>();
     private final Map<Object, TinkerElementContainer<TinkerEdge>> edges = new 
ConcurrentHashMap<>();
 
@@ -131,6 +138,9 @@ public final class TinkerStorageGraph extends 
AbstractTinkerGraph {
                 } finally {
                     loading = false;
                 }
+                // recreate the recorded indexes now that replay has rebuilt 
the elements they cover, so
+                // createKeyIndex backfills over the restored data rather than 
only over writes that follow
+                restoreIndexes(dir);
             } catch (RuntimeException | Error ex) {
                 // don't leak the lock if the engine fails to open or replay
                 directoryLock.close();
@@ -609,6 +619,7 @@ public final class TinkerStorageGraph extends 
AbstractTinkerGraph {
         } else {
             throw new IllegalArgumentException("Class is not indexable: " + 
elementClass);
         }
+        recordIndexes();
     }
 
     /**
@@ -627,5 +638,36 @@ public final class TinkerStorageGraph extends 
AbstractTinkerGraph {
         } else {
             throw new IllegalArgumentException("Class is not indexable: " + 
elementClass);
         }
+        recordIndexes();
+    }
+
+    /**
+     * Recreate the indexes recorded for this store. Runs after replay so that 
{@code createKeyIndex} backfills over
+     * the elements it has just rebuilt. The definitions are already on disk, 
so recording is suppressed while they
+     * are applied.
+     */
+    private void restoreIndexes(final File directory) {
+        final IndexDefinitions definitions = IndexDefinitions.read(directory);
+        if (definitions.isEmpty())
+            return;
+        restoringIndexes = true;
+        try {
+            definitions.vertexKeys().forEach(key -> createIndex(key, 
Vertex.class));
+            definitions.edgeKeys().forEach(key -> createIndex(key, 
Edge.class));
+        } finally {
+            restoringIndexes = false;
+        }
+    }
+
+    /**
+     * Record the current set of indexed keys beside the engine's files, so a 
reopen restores them. Index definitions
+     * are not part of the transactional log; see {@link IndexDefinitions} for 
why that is sound. A graph with no
+     * storage engine keeps everything in memory and writes nothing.
+     */
+    private void recordIndexes() {
+        if (null == storage || restoringIndexes)
+            return;
+        new IndexDefinitions(getIndexedKeys(Vertex.class), 
getIndexedKeys(Edge.class))
+                .write(new File(storageDirectory));
     }
 }
diff --git 
a/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/IndexDefinitions.java
 
b/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/IndexDefinitions.java
new file mode 100644
index 0000000000..07e63b9bd6
--- /dev/null
+++ 
b/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/IndexDefinitions.java
@@ -0,0 +1,205 @@
+/*
+ * 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.tinkergraph.structure.storage;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.nio.channels.FileChannel;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * The set of property keys a persistent graph indexes, held in a small file 
beside the storage engine's own files.
+ * <p/>
+ * Index definitions are deliberately kept out of the transactional log. An 
index is purely an optimization, so losing
+ * a definition to a crash costs a rebuild rather than any data: a crash 
between {@code createIndex} and the write here
+ * drops a definition the caller can recreate, and one after {@code dropIndex} 
resurrects an index that is dropped
+ * again. Neither can change a query result, which is what makes a file 
outside the write-ahead log an honest place to
+ * keep this. Nothing that affects correctness may be stored this way.
+ * <p/>
+ * The file is line oriented and readable, one record per line: {@code V} or 
{@code E}, a tab, then the property key
+ * with backslash, tab, newline and carriage return escaped, so a key 
containing any of them survives a round trip.
+ */
+public final class IndexDefinitions {
+
+    private static final Logger logger = 
LoggerFactory.getLogger(IndexDefinitions.class);
+
+    static final String INDEX_FILE = "INDEXES";
+
+    private static final String VERTEX = "V";
+    private static final String EDGE = "E";
+
+    private final Set<String> vertexKeys;
+    private final Set<String> edgeKeys;
+
+    public IndexDefinitions(final Set<String> vertexKeys, final Set<String> 
edgeKeys) {
+        this.vertexKeys = new LinkedHashSet<>(vertexKeys);
+        this.edgeKeys = new LinkedHashSet<>(edgeKeys);
+    }
+
+    public Set<String> vertexKeys() {
+        return vertexKeys;
+    }
+
+    public Set<String> edgeKeys() {
+        return edgeKeys;
+    }
+
+    public boolean isEmpty() {
+        return vertexKeys.isEmpty() && edgeKeys.isEmpty();
+    }
+
+    /**
+     * Read the definitions recorded in {@code directory}, or an empty set if 
none have been recorded.
+     * <p/>
+     * A file that cannot be read is reported and treated as empty rather than 
raised. The graph then opens with no
+     * indexes, which is exactly how it behaved before definitions were 
recorded at all, so an unreadable file can
+     * never make a store less openable than the data it holds.
+     */
+    public static IndexDefinitions read(final File directory) {
+        final File file = new File(directory, INDEX_FILE);
+        if (!file.isFile())
+            return new IndexDefinitions(new LinkedHashSet<>(), new 
LinkedHashSet<>());
+
+        final Set<String> vertexKeys = new LinkedHashSet<>();
+        final Set<String> edgeKeys = new LinkedHashSet<>();
+        try {
+            final List<String> lines = Files.readAllLines(file.toPath(), 
StandardCharsets.UTF_8);
+            for (final String line : lines) {
+                if (line.isEmpty() || line.charAt(0) == '#')
+                    continue;
+                final int tab = line.indexOf('\t');
+                if (tab < 0)
+                    throw new IOException("Malformed index definition line: " 
+ line);
+                final String key = unescape(line.substring(tab + 1));
+                switch (line.substring(0, tab)) {
+                    case VERTEX: vertexKeys.add(key); break;
+                    case EDGE: edgeKeys.add(key); break;
+                    default: throw new IOException("Unknown index element type 
in line: " + line);
+                }
+            }
+        } catch (IOException ex) {
+            logger.warn("Could not read index definitions from {}; opening 
with no indexes. " +
+                    "Recreate them with createIndex() if they are wanted.", 
file, ex);
+            return new IndexDefinitions(new LinkedHashSet<>(), new 
LinkedHashSet<>());
+        }
+        return new IndexDefinitions(vertexKeys, edgeKeys);
+    }
+
+    /**
+     * Replace the definitions recorded in {@code directory}. Written to a 
temporary file, forced to the device and
+     * renamed into place, so a crash leaves either the previous set or the 
new one and never a partial file.
+     */
+    public void write(final File directory) {
+        final File file = new File(directory, INDEX_FILE);
+        if (isEmpty()) {
+            try {
+                Files.deleteIfExists(file.toPath());
+                syncDirectory(directory);
+            } catch (IOException ex) {
+                logger.warn("Could not remove index definitions file {}", 
file, ex);
+            }
+            return;
+        }
+
+        final File tmp = new File(directory, INDEX_FILE + ".tmp");
+        try {
+            try (final FileOutputStream fos = new FileOutputStream(tmp);
+                 final Writer out = new OutputStreamWriter(fos, 
StandardCharsets.UTF_8)) {
+                out.write("# TinkerGraph index definitions; recreated on 
open\n");
+                for (final String key : vertexKeys)
+                    out.write(VERTEX + '\t' + escape(key) + '\n');
+                for (final String key : edgeKeys)
+                    out.write(EDGE + '\t' + escape(key) + '\n');
+                out.flush();
+                fos.getFD().sync();
+            }
+            atomicMove(tmp, file);
+            syncDirectory(directory);
+        } catch (IOException ex) {
+            logger.warn("Could not record index definitions in {}; they will 
not survive a reopen", file, ex);
+        }
+    }
+
+    private static String escape(final String key) {
+        final StringBuilder sb = new StringBuilder(key.length());
+        for (int i = 0; i < key.length(); i++) {
+            final char c = key.charAt(i);
+            switch (c) {
+                case '\\': sb.append("\\\\"); break;
+                case '\t': sb.append("\\t"); break;
+                case '\n': sb.append("\\n"); break;
+                case '\r': sb.append("\\r"); break;
+                default: sb.append(c);
+            }
+        }
+        return sb.toString();
+    }
+
+    private static String unescape(final String value) throws IOException {
+        final StringBuilder sb = new StringBuilder(value.length());
+        for (int i = 0; i < value.length(); i++) {
+            final char c = value.charAt(i);
+            if (c != '\\') {
+                sb.append(c);
+                continue;
+            }
+            if (++i == value.length())
+                throw new IOException("Index definition ends with a dangling 
escape: " + value);
+            switch (value.charAt(i)) {
+                case '\\': sb.append('\\'); break;
+                case 't': sb.append('\t'); break;
+                case 'n': sb.append('\n'); break;
+                case 'r': sb.append('\r'); break;
+                default: throw new IOException("Unknown escape in index 
definition: " + value);
+            }
+        }
+        return sb.toString();
+    }
+
+    private static void atomicMove(final File source, final File target) 
throws IOException {
+        try {
+            Files.move(source.toPath(), target.toPath(),
+                    StandardCopyOption.ATOMIC_MOVE, 
StandardCopyOption.REPLACE_EXISTING);
+        } catch (AtomicMoveNotSupportedException anse) {
+            Files.move(source.toPath(), target.toPath(), 
StandardCopyOption.REPLACE_EXISTING);
+        }
+    }
+
+    private static void syncDirectory(final File directory) {
+        try (final FileChannel dirChannel = 
FileChannel.open(directory.toPath(), StandardOpenOption.READ)) {
+            dirChannel.force(true);
+        } catch (IOException ex) {
+            // some platforms (notably Windows) cannot open a directory as a 
channel; the atomic rename is the
+            // durability guarantee there, so treat inability to sync the 
directory as non-fatal
+        }
+    }
+}
diff --git 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorageTest.java
 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorageTest.java
index c001d94afd..61af24234c 100644
--- 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorageTest.java
+++ 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorageTest.java
@@ -18,7 +18,10 @@
  */
 package org.apache.tinkerpop.gremlin.tinkergraph.structure.storage;
 
+import org.apache.commons.configuration2.BaseConfiguration;
 import org.apache.commons.configuration2.Configuration;
+import org.apache.tinkerpop.gremlin.structure.Edge;
+import org.apache.tinkerpop.gremlin.structure.Graph;
 import org.apache.tinkerpop.gremlin.structure.T;
 import org.apache.tinkerpop.gremlin.structure.Vertex;
 import org.apache.tinkerpop.gremlin.structure.VertexProperty;
@@ -33,7 +36,9 @@ import java.io.File;
 import java.io.IOException;
 import java.io.RandomAccessFile;
 import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.Map;
@@ -313,6 +318,106 @@ public class GraphBinaryStorageTest extends 
AbstractTinkerStorageConformanceTest
         graph.close();
     }
 
+    @Test
+    public void shouldRestoreIndexDefinitionsOnReopen() throws Exception {
+        TinkerStorageGraph graph = open();
+        graph.createIndex("name", Vertex.class);
+        graph.createIndex("weight", Edge.class);
+        final Vertex marko = graph.addVertex(T.id, 1, "name", "marko");
+        final Vertex josh = graph.addVertex(T.id, 2, "name", "josh");
+        marko.addEdge("knows", josh, T.id, 10, "weight", 0.5d);
+        graph.tx().commit();
+        graph.close();
+
+        graph = open();
+        try {
+            // the definitions come back, and the index covers data written 
before the restart rather than only
+            // writes that follow it
+            assertEquals(Collections.singleton("name"), 
graph.getIndexedKeys(Vertex.class));
+            assertEquals(Collections.singleton("weight"), 
graph.getIndexedKeys(Edge.class));
+            assertEquals(1L, (long) graph.traversal().V().has("name", 
"josh").count().next());
+            assertEquals(1L, (long) graph.traversal().E().has("weight", 
0.5d).count().next());
+        } finally {
+            graph.close();
+        }
+    }
+
+    @Test
+    public void shouldNotRestoreADroppedIndex() throws Exception {
+        TinkerStorageGraph graph = open();
+        graph.createIndex("name", Vertex.class);
+        graph.addVertex(T.id, 1, "name", "marko");
+        graph.tx().commit();
+        graph.dropIndex("name", Vertex.class);
+        graph.close();
+
+        graph = open();
+        try {
+            assertTrue("a dropped index must not come back", 
graph.getIndexedKeys(Vertex.class).isEmpty());
+            // data is unaffected by the index being gone; the lookup just 
falls back to a scan
+            assertEquals(1L, (long) graph.traversal().V().has("name", 
"marko").count().next());
+        } finally {
+            graph.close();
+        }
+    }
+
+    @Test
+    public void shouldOpenWithoutIndexesWhenDefinitionsAreUnreadable() throws 
Exception {
+        TinkerStorageGraph graph = open();
+        final String location = 
graph.configuration().getString(TinkerGraph.GREMLIN_TINKERGRAPH_STORAGE_DIRECTORY);
+        graph.createIndex("name", Vertex.class);
+        graph.addVertex(T.id, 1, "name", "marko");
+        graph.tx().commit();
+        graph.close();
+
+        // a corrupt sidecar must degrade to the behaviour before definitions 
were recorded at all, never block a
+        // store whose data is perfectly readable
+        Files.write(new File(location, IndexDefinitions.INDEX_FILE).toPath(),
+                "this is not an index 
definition".getBytes(StandardCharsets.UTF_8));
+
+        graph = open();
+        try {
+            assertTrue(graph.getIndexedKeys(Vertex.class).isEmpty());
+            assertEquals(1L, (long) graph.traversal().V().has("name", 
"marko").count().next());
+        } finally {
+            graph.close();
+        }
+    }
+
+    @Test
+    public void shouldRoundTripIndexKeysNeedingEscapes() throws Exception {
+        final String awkward = "a\tb\nc\\d";
+        TinkerStorageGraph graph = open();
+        graph.createIndex(awkward, Vertex.class);
+        graph.addVertex(T.id, 1, awkward, "value");
+        graph.tx().commit();
+        graph.close();
+
+        graph = open();
+        try {
+            assertEquals(Collections.singleton(awkward), 
graph.getIndexedKeys(Vertex.class));
+            assertEquals(1L, (long) graph.traversal().V().has(awkward, 
"value").count().next());
+        } finally {
+            graph.close();
+        }
+    }
+
+    @Test
+    public void shouldNotRecordIndexesWithoutAStorageEngine() {
+        // a TinkerStorageGraph with no engine is transactional but in-memory, 
so it must touch no disk at all
+        final Configuration conf = new BaseConfiguration();
+        conf.setProperty(Graph.GRAPH, TinkerStorageGraph.class.getName());
+        final TinkerStorageGraph graph = TinkerStorageGraph.open(conf);
+        try {
+            graph.createIndex("name", Vertex.class);
+            graph.addVertex(T.id, 1, "name", "marko");
+            graph.tx().commit();
+            assertEquals(Collections.singleton("name"), 
graph.getIndexedKeys(Vertex.class));
+        } finally {
+            graph.close();
+        }
+    }
+
     @Test
     public void shouldFailOnCorruptFrameWithBadCrc() throws Exception {
         TinkerStorageGraph graph = open();

Reply via email to