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

commit af40a8cc368f5a47a2f7461dcc52a8267784e180
Author: Stephen Mallette <[email protected]>
AuthorDate: Tue Aug 4 19:41:10 2026 +0000

    Add crash-consistency and streaming-scale tests for TinkerStorageGraph 
storage
    
    Fills the storage test gaps left after the durability/integrity work.
    
    StorageCrashConsistencyTest reconstructs the exact on-disk states a crash
    leaves at each step of the write-ahead commit and compaction sequences —
    durable-commit-without-snapshot, snapshot-plus-log, stray temp snapshot 
before
    rename, and new-snapshot-with-log-not-yet-deleted — and asserts each 
reopens to
    the correct graph, deterministically and without killing a JVM.
    
    GraphBinaryStorageTest gains a scaled snapshot-streaming test (500 vertices 
+
    499 edges must write exactly one frame per element) as a bounded-memory 
proxy,
    and documents why real fsync/power-loss durability is out of unit-test scope
    (needs OS-level fault injection).
    
    Assisted-by: Claude Code:claude-opus-4-8
---
 .../structure/storage/GraphBinaryStorageTest.java  |  70 ++++++++-
 .../storage/StorageCrashConsistencyTest.java       | 173 +++++++++++++++++++++
 2 files changed, 238 insertions(+), 5 deletions(-)

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 34e67ab93d..7daf2eb5ed 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
@@ -37,7 +37,14 @@ import static org.junit.Assert.fail;
 
 /**
  * Runs the shared {@link AbstractTinkerStorageConformanceTest} suite against 
the {@link GraphBinaryStorage} engine and
- * adds engine-specific tests for the on-disk log layout.
+ * adds engine-specific tests for the on-disk log layout, sync modes, 
auto-compaction, snapshot streaming, and
+ * corruption detection.
+ * <p/>
+ * Not covered here (deliberately): true {@code fsync} durability against OS 
crash or power loss. The {@code commit}
+ * vs. {@code os} sync-mode tests verify configuration and a graceful 
round-trip, but a JVM unit test cannot prove that
+ * an acknowledged commit survives a kernel crash — that needs OS-level fault 
injection (e.g. a FUSE layer that drops
+ * un-synced writes, or {@code dm-flakey}), which is out of scope. 
Crash-*consistency* of the file layout (as opposed
+ * to device-level durability) is covered deterministically by {@link 
StorageCrashConsistencyTest}.
  */
 public class GraphBinaryStorageTest extends 
AbstractTinkerStorageConformanceTest {
 
@@ -135,6 +142,43 @@ public class GraphBinaryStorageTest extends 
AbstractTinkerStorageConformanceTest
         reopened.close();
     }
 
+    @Test
+    public void shouldStreamSnapshotFrameByFrameAtScale() {
+        // Bounded-memory proxy for the streaming snapshot path: rather than 
measure heap (flaky, JVM-dependent), assert
+        // the observable streaming property holds at scale — a large graph is 
written as exactly one frame per element,
+        // never one whole-graph frame — and round-trips intact. This is the 
property that keeps compaction from
+        // materializing a second full copy of the graph in memory; it is not 
a hard OOM assertion.
+        final int vertexCount = 500;
+        final int edgeCount = 499;
+        final TinkerStorageGraph graph = open();
+        final String location = 
graph.configuration().getString(TinkerGraph.GREMLIN_TINKERGRAPH_GRAPH_LOCATION);
+        try {
+            for (int i = 0; i < vertexCount; i++)
+                graph.addVertex(T.id, i, "value", i);
+            for (int i = 0; i < edgeCount; i++)
+                graph.vertices(i).next().addEdge("next", graph.vertices(i + 
1).next(), T.id, 1_000_000 + i);
+            graph.tx().commit();
+            graph.compact();
+
+            try {
+                assertEquals(vertexCount + edgeCount, countFrames(new 
File(location, GraphBinaryStorage.SNAPSHOT_FILE)));
+            } catch (Exception ex) {
+                throw new RuntimeException(ex);
+            }
+        } finally {
+            graph.close();
+        }
+
+        final TinkerStorageGraph reopened = open();
+        try {
+            assertEquals(vertexCount, countOf(reopened.vertices()));
+            assertEquals(edgeCount, countOf(reopened.edges()));
+            assertEquals(Integer.valueOf(499), 
reopened.vertices(499).next().value("value"));
+        } finally {
+            reopened.close();
+        }
+    }
+
     /**
      * Count the framed records in a storage file: a fixed header ({@code 
HEADER_SIZE} bytes) followed by frames of a
      * 4-byte big-endian payload length, a 4-byte CRC, then that many payload 
bytes.
@@ -142,8 +186,7 @@ public class GraphBinaryStorageTest extends 
AbstractTinkerStorageConformanceTest
     private static int countFrames(final File file) throws Exception {
         int frames = 0;
         try (final DataInputStream in = new DataInputStream(new 
java.io.BufferedInputStream(new java.io.FileInputStream(file)))) {
-            final long headerSkipped = in.skip(GraphBinaryStorage.HEADER_SIZE);
-            if (headerSkipped < GraphBinaryStorage.HEADER_SIZE) return 0;
+            if (!skipFully(in, GraphBinaryStorage.HEADER_SIZE)) return 0;
             while (true) {
                 final int len;
                 try {
@@ -152,14 +195,31 @@ public class GraphBinaryStorageTest extends 
AbstractTinkerStorageConformanceTest
                     break;
                 }
                 in.readInt(); // CRC
-                final long skipped = in.skip(len);
-                if (skipped < len) break;
+                if (!skipFully(in, len)) break;
                 frames++;
             }
         }
         return frames;
     }
 
+    /**
+     * Skip exactly {@code n} bytes, reading in a loop because a single {@link 
DataInputStream#skip} may skip fewer.
+     * Returns false if EOF is reached first.
+     */
+    private static boolean skipFully(final DataInputStream in, final long n) 
throws Exception {
+        long left = n;
+        while (left > 0) {
+            final long s = in.skip(left);
+            if (s <= 0) {
+                if (in.read() < 0) return false; // genuine EOF
+                left -= 1;
+            } else {
+                left -= s;
+            }
+        }
+        return true;
+    }
+
     @Test
     public void shouldAutoCompactWhenLogExceedsThreshold() {
         // a small threshold makes automatic compaction fire mid-run, without 
any explicit compact()/close()
diff --git 
a/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/StorageCrashConsistencyTest.java
 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/StorageCrashConsistencyTest.java
new file mode 100644
index 0000000000..614cfdb0ba
--- /dev/null
+++ 
b/tinkergraph-gremlin/src/test/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/StorageCrashConsistencyTest.java
@@ -0,0 +1,173 @@
+/*
+ * 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.apache.commons.configuration2.BaseConfiguration;
+import org.apache.commons.configuration2.Configuration;
+import org.apache.tinkerpop.gremlin.structure.Graph;
+import org.apache.tinkerpop.gremlin.structure.T;
+import org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerGraph;
+import org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerStorageGraph;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Iterator;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Crash-consistency tests for {@link GraphBinaryStorage}. Rather than kill a 
JVM mid-operation — which is slow and
+ * non-deterministic — these reconstruct the exact on-disk states a crash 
would leave at each step of the two durable
+ * sequences (the write-ahead commit and compaction) and assert that reopening 
recovers the correct graph. The
+ * invariant under test: at no step may a crash leave the store unable to 
recover the last committed state.
+ */
+public class StorageCrashConsistencyTest {
+
+    @Rule
+    public TemporaryFolder tempFolder = new TemporaryFolder();
+
+    private String location;
+    private File snapshotFile;
+    private File logFile;
+    private File tmpSnapshotFile;
+
+    @Before
+    public void setUp() throws Exception {
+        final File dir = tempFolder.newFolder("storage");
+        location = dir.getAbsolutePath();
+        snapshotFile = new File(dir, GraphBinaryStorage.SNAPSHOT_FILE);
+        logFile = new File(dir, GraphBinaryStorage.LOG_FILE);
+        tmpSnapshotFile = new File(dir, GraphBinaryStorage.SNAPSHOT_FILE + 
".tmp");
+    }
+
+    private Configuration config() {
+        final Configuration conf = new BaseConfiguration();
+        conf.setProperty(Graph.GRAPH, TinkerStorageGraph.class.getName());
+        conf.setProperty(TinkerGraph.GREMLIN_TINKERGRAPH_STORAGE, 
"graphbinary");
+        conf.setProperty(TinkerGraph.GREMLIN_TINKERGRAPH_GRAPH_LOCATION, 
location);
+        // disable auto-compaction so tests control exactly when compaction 
happens
+        
conf.setProperty(TinkerGraph.GREMLIN_TINKERGRAPH_STORAGE_COMPACT_THRESHOLD, 0L);
+        return conf;
+    }
+
+    private TinkerStorageGraph open() {
+        return TinkerStorageGraph.open(config());
+    }
+
+    /**
+     * Captured building blocks of valid on-disk files, used to assemble crash 
states: a snapshot holding {1}, a log
+     * holding a later commit of {2}, and a compacted snapshot holding {1,2}.
+     */
+    private byte[] snapshotV1;
+    private byte[] logV2;
+    private byte[] snapshotV12;
+
+    private void captureBuildingBlocks() throws IOException {
+        // snapshot holding vertex 1 (compaction on close folds the single 
commit into the snapshot)
+        TinkerStorageGraph g = open();
+        g.addVertex(T.id, 1, "value", 1);
+        g.tx().commit();
+        g.close();
+        snapshotV1 = Files.readAllBytes(snapshotFile.toPath());
+
+        // a valid log holding a later commit of vertex 2, captured before the 
closing compaction folds it away
+        g = open(); // replays {1} from the snapshot
+        g.addVertex(T.id, 2, "value", 2);
+        g.tx().commit();
+        g.tx().close();
+        logV2 = Files.readAllBytes(logFile.toPath());
+        g.close(); // compacts {1,2} into the snapshot
+        snapshotV12 = Files.readAllBytes(snapshotFile.toPath());
+
+        // reset the directory to a clean slate for the state under test
+        resetFiles();
+    }
+
+    private void resetFiles() throws IOException {
+        Files.deleteIfExists(snapshotFile.toPath());
+        Files.deleteIfExists(logFile.toPath());
+        Files.deleteIfExists(tmpSnapshotFile.toPath());
+    }
+
+    private void assertReopensTo(final int... expectedIds) {
+        final TinkerStorageGraph graph = open();
+        try {
+            assertEquals(expectedIds.length, countOf(graph.vertices()));
+            for (final int id : expectedIds)
+                assertEquals(Integer.valueOf(id), 
graph.vertices(id).next().value("value"));
+        } finally {
+            graph.close();
+        }
+    }
+
+    @Test
+    public void shouldRecoverDurableCommitWithNoSnapshot() throws Exception {
+        // WAL guarantee: a commit whose frame was durably written to the log, 
with no compaction having run, must
+        // recover on reopen even though no snapshot exists.
+        captureBuildingBlocks();
+        Files.write(logFile.toPath(), logV2);
+        assertReopensTo(2);
+    }
+
+    @Test
+    public void shouldRecoverFromSnapshotPlusLog() throws Exception {
+        // steady pre-compaction state: a snapshot holding earlier commits and 
a log holding later ones. Reopen must
+        // fold snapshot-then-log into the union.
+        captureBuildingBlocks();
+        Files.write(snapshotFile.toPath(), snapshotV1);
+        Files.write(logFile.toPath(), logV2);
+        assertReopensTo(1, 2);
+    }
+
+    @Test
+    public void shouldIgnoreStrayTempSnapshotFromCrashBeforeRename() throws 
Exception {
+        // crash after writing snapshot.gbin.tmp but before the atomic rename: 
the old snapshot + log are intact and
+        // the stray .tmp must be ignored, so the last committed state still 
recovers.
+        captureBuildingBlocks();
+        Files.write(snapshotFile.toPath(), snapshotV1);
+        Files.write(logFile.toPath(), logV2);
+        Files.write(tmpSnapshotFile.toPath(), new byte[]{ 0x00, 0x01, 0x02, 
0x03 }); // garbage half-written temp
+        assertReopensTo(1, 2);
+    }
+
+    @Test
+    public void shouldRecoverFromNewSnapshotWithLogNotYetDeleted() throws 
Exception {
+        // crash after the rename installed the new snapshot but before the 
log was truncated: snapshot holds {1,2}
+        // and the stale log still holds {2}. Folding snapshot-then-log is 
idempotent (last write per id wins), so the
+        // result is exactly {1,2} — never a lost or duplicated element.
+        captureBuildingBlocks();
+        Files.write(snapshotFile.toPath(), snapshotV12);
+        Files.write(logFile.toPath(), logV2);
+        assertReopensTo(1, 2);
+    }
+
+    private static long countOf(final Iterator<?> it) {
+        long count = 0;
+        while (it.hasNext()) {
+            it.next();
+            count++;
+        }
+        return count;
+    }
+}

Reply via email to