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 7fac380d8cb33423f943f5180dc0c8ce39ea7397
Author: Stephen Mallette <[email protected]>
AuthorDate: Tue Aug 4 22:19:28 2026 +0000

    Record TinkerStorageGraph format version once per store
    
    The format version lived in every file header, so a store holding a snapshot
    and log written at different times could carry two versions that disagree.
    It now lives once in a store-level VERSION marker (magic + version, 
fsynced),
    the single source of truth; per-file headers keep only the magic for 
identity
    and corruption detection. Opening a store whose marker names an unsupported
    version fails loudly and points to g.io() export as the migration path — the
    engine deliberately does no in-place migration. Unknown record opcodes 
remain
    a hard error, so a genuinely incompatible change bumps the version.
    
    Assisted-by: Claude Code:claude-opus-4-8
---
 .../structure/storage/GraphBinaryStorage.java      | 89 +++++++++++++++++++---
 .../structure/storage/GraphBinaryStorageTest.java  | 24 ++++++
 2 files changed, 101 insertions(+), 12 deletions(-)

diff --git 
a/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorage.java
 
b/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorage.java
index 715dc48c74..2aaa7643cc 100644
--- 
a/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorage.java
+++ 
b/tinkergraph-gremlin/src/main/java/org/apache/tinkerpop/gremlin/tinkergraph/structure/storage/GraphBinaryStorage.java
@@ -67,6 +67,15 @@ import java.util.zip.CRC32;
  * (default) {@code fsync}s so the commit survives an OS crash or power loss, 
while {@link SyncMode#OS} only flushes to
  * the operating system.
  * <p/>
+ * On-disk compatibility: the store's format version is recorded once in a 
{@code VERSION} marker file (magic +
+ * version), which is the single source of truth even when a store momentarily 
holds a snapshot and a log written at
+ * different times. Individual files carry only the magic for 
identity/corruption detection. Opening a store whose
+ * marker names an unsupported version fails loudly — records are never 
misread across a format change. The engine
+ * keeps this simple by not attempting in-place migration: the supported path 
across an incompatible format bump is to
+ * export via {@code g.io()} before upgrading. Additive changes should prefer 
new record opcodes; unknown opcodes are
+ * a hard error (a durable store must not silently drop records it cannot 
parse), so a genuinely incompatible change
+ * bumps the version.
+ * <p/>
  * The in-memory graph remains authoritative (write-through). This engine does 
not support graphs larger than memory.
  * <p/>
  * Known limitation (write amplification): a commit records each changed 
element in full — a single property change on
@@ -84,15 +93,24 @@ public final class GraphBinaryStorage implements 
TinkerStorage {
     static final byte[] MAGIC = { 'T', 'G', 'S', 'B' };
 
     /**
-     * On-disk format version, written once in each file's header after {@link 
#MAGIC}. A future format bump is
-     * detected here so old files can be rejected (or, later, migrated) rather 
than misread.
+     * On-disk format version of the store. Recorded once per store in the 
{@link #VERSION_FILE} marker rather than in
+     * every file, so a store has a single unambiguous version even when it 
momentarily holds a snapshot and a log
+     * written at different times. A future format bump is detected against 
this marker so an older store is rejected
+     * (never silently misread); the supported migration path is to export via 
{@code g.io()} before upgrading.
      */
     static final byte FORMAT_VERSION = 1;
 
     /**
-     * Bytes of the fixed file header: {@link #MAGIC} followed by the one-byte 
{@link #FORMAT_VERSION}.
+     * Store-level version marker file, holding {@link #MAGIC} followed by the 
one-byte {@link #FORMAT_VERSION}. It is
+     * the single source of truth for the store's format version.
+     */
+    static final String VERSION_FILE = "VERSION";
+
+    /**
+     * Bytes of the per-file header: just {@link #MAGIC}. The format version 
lives in the store-level
+     * {@link #VERSION_FILE}, not in each file.
      */
-    static final int HEADER_SIZE = MAGIC.length + 1;
+    static final int HEADER_SIZE = MAGIC.length;
 
     private static final byte OP_PUT_VERTEX = 1;
     private static final byte OP_DEL_VERTEX = 2;
@@ -108,6 +126,7 @@ public final class GraphBinaryStorage implements 
TinkerStorage {
     private File directory;
     private File snapshotFile;
     private File logFile;
+    private File versionFile;
 
     /**
      * Default automatic-compaction threshold: 64 MB of appended log since the 
last compaction.
@@ -130,12 +149,61 @@ public final class GraphBinaryStorage implements 
TinkerStorage {
         this.directory = new File(location);
         this.snapshotFile = new File(directory, SNAPSHOT_FILE);
         this.logFile = new File(directory, LOG_FILE);
+        this.versionFile = new File(directory, VERSION_FILE);
         this.syncMode = 
SyncMode.fromConfigValue(config.getString(TinkerGraph.GREMLIN_TINKERGRAPH_STORAGE_SYNC,
 null));
         this.compactThresholdBytes = config.getLong(
                 TinkerGraph.GREMLIN_TINKERGRAPH_STORAGE_COMPACT_THRESHOLD, 
DEFAULT_COMPACT_THRESHOLD_BYTES);
         // seed the counter with any pre-existing log so a graph reopened with 
a large log still compacts promptly
         this.logBytesSinceCompaction = logFile.exists() ? logFile.length() : 0;
         ensureDirectory();
+        establishStoreVersion();
+    }
+
+    /**
+     * Read and validate the store-level version marker, or create it for a 
new store. This is the single source of
+     * truth for the store's format version: a marker naming an unsupported 
version, or bad magic, fails the open
+     * loudly rather than risking a misread. The supported path across an 
incompatible format bump is to export the
+     * graph via {@code g.io()} before upgrading.
+     */
+    private void establishStoreVersion() {
+        // an existing store (has a snapshot or log) written before the marker 
existed is treated as version 1
+        final boolean storeHasData = snapshotFile.exists() || logFile.exists();
+        if (!versionFile.exists()) {
+            if (storeHasData && FORMAT_VERSION != 1)
+                throw new IllegalStateException(String.format(
+                        "Storage location %s has data but no version marker; 
cannot confirm it is format version %d",
+                        directory, FORMAT_VERSION));
+            writeStoreVersion();
+            return;
+        }
+        try (final DataInputStream in = new DataInputStream(new 
java.io.BufferedInputStream(new FileInputStream(versionFile)))) {
+            final byte[] magic = new byte[MAGIC.length];
+            readFully(in, magic);
+            if (!Arrays.equals(magic, MAGIC))
+                throw new IOException(String.format("%s is not a TinkerGraph 
storage version marker (bad magic)", versionFile));
+            final byte version = in.readByte();
+            if (version != FORMAT_VERSION)
+                throw new IOException(String.format(
+                        "Unsupported storage format version %d at %s (this 
build writes %d); export via g.io() before upgrading",
+                        version, directory, FORMAT_VERSION));
+        } catch (IOException ex) {
+            throw new UncheckedIOException(String.format("Could not read 
storage version marker %s", versionFile), ex);
+        }
+    }
+
+    /**
+     * Write the store-level version marker ({@link #MAGIC} + {@link 
#FORMAT_VERSION}) durably.
+     */
+    private void writeStoreVersion() {
+        try (final FileOutputStream fos = new FileOutputStream(versionFile);
+             final DataOutputStream out = new DataOutputStream(fos)) {
+            out.write(MAGIC);
+            out.writeByte(FORMAT_VERSION);
+            out.flush();
+            fos.getFD().sync();
+        } catch (IOException ex) {
+            throw new UncheckedIOException(String.format("Could not write 
storage version marker %s", versionFile), ex);
+        }
     }
 
     /**
@@ -195,8 +263,9 @@ public final class GraphBinaryStorage implements 
TinkerStorage {
     }
 
     /**
-     * Read and validate the fixed file header, returning the number of record 
bytes that follow it. An empty file
-     * (freshly created, no header yet) is treated as having no records.
+     * Read and validate the per-file header (magic only), returning the 
number of record bytes that follow it. An
+     * empty file (freshly created, no header yet) is treated as having no 
records. The store's format version is
+     * validated once against the {@link #VERSION_FILE} marker in {@link 
#open}, not here.
      */
     private long readAndVerifyHeader(final DataInputStream in, final File 
file, final long fileLength) throws IOException {
         if (fileLength == 0)
@@ -207,10 +276,6 @@ public final class GraphBinaryStorage implements 
TinkerStorage {
         readFully(in, magic);
         if (!Arrays.equals(magic, MAGIC))
             throw new IOException(String.format("%s is not a TinkerGraph 
storage file (bad magic)", file));
-        final byte version = in.readByte();
-        if (version != FORMAT_VERSION)
-            throw new IOException(String.format(
-                    "Unsupported storage format version %d in %s (expected 
%d)", version, file, FORMAT_VERSION));
         return fileLength - HEADER_SIZE;
     }
 
@@ -441,11 +506,11 @@ public final class GraphBinaryStorage implements 
TinkerStorage {
     }
 
     /**
-     * Write the fixed file header ({@link #MAGIC} + {@link #FORMAT_VERSION}) 
at the start of a storage file.
+     * Write the per-file header ({@link #MAGIC}) at the start of a storage 
file. The format version is recorded once
+     * per store in the {@link #VERSION_FILE} marker, not per file.
      */
     private static void writeHeader(final DataOutputStream out) throws 
IOException {
         out.write(MAGIC);
-        out.writeByte(FORMAT_VERSION);
     }
 
     private void closeLog() {
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 7daf2eb5ed..c113511176 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
@@ -360,6 +360,30 @@ public class GraphBinaryStorageTest extends 
AbstractTinkerStorageConformanceTest
         }
     }
 
+    @Test
+    public void shouldFailOnStoreWithUnsupportedVersionMarker() throws 
Exception {
+        TinkerStorageGraph graph = open();
+        final String location = 
graph.configuration().getString(TinkerGraph.GREMLIN_TINKERGRAPH_GRAPH_LOCATION);
+        graph.addVertex(T.id, 1);
+        graph.tx().commit();
+        graph.close();
+
+        // rewrite the store-level version marker to name a future, 
unsupported format version
+        final File versionFile = new File(location, 
GraphBinaryStorage.VERSION_FILE);
+        final byte[] marker = Files.readAllBytes(versionFile.toPath());
+        marker[marker.length - 1] = 99; // version byte follows the magic
+        Files.write(versionFile.toPath(), marker);
+
+        try {
+            open();
+            fail("expected reopen to fail on an unsupported store version");
+        } catch (Exception expected) {
+            assertTrue("cause should report the unsupported version and 
migration path: " + rootMessage(expected),
+                    rootMessage(expected).contains("Unsupported storage format 
version")
+                            && rootMessage(expected).contains("g.io()"));
+        }
+    }
+
     private static String rootMessage(final Throwable t) {
         Throwable cur = t;
         while (cur.getCause() != null && cur.getCause() != cur)

Reply via email to