Skip to content

LSM_VECTOR index in-memory bloat: VectorLocation objects never removed from ConcurrentHashMap (OOM after weeks of operation) #5516

Closed

Description

@KyaniteSolutions

Summary

The VectorLocationIndex maintains an in-memory ConcurrentHashMap that accumulates VectorLocation entries on every put() call. Stale entries (from re-embedding unchanged or updated vertices) are never removed from memory — not by DROP INDEX, not by index recreation, and not by GC. They persist until the database is closed and reopened.

In production, this caused 9.3 million VectorLocation objects (~970MB heap) for only ~4,029 active vectors, leading to OutOfMemoryError on a 1500MB heap.

Environment

  • ArcadeDB: 26.5.1
  • JVM: OpenJDK 21.0.5
  • OS: macOS / Linux
  • Index config: LSM_VECTOR, 384 dimensions, COSINE, INT8 quantization

Reproduction

Steps

  1. Create a vertex type with embedding property + LSM_VECTOR index
  2. Store 1,000 embeddings
  3. Re-store the same 1,000 embeddings 10 times (simulating periodic sync cycles)
  4. Heap histogram shows ~10,000 VectorLocation objects
  5. DROP INDEX + CREATE INDEX — stale entries still in memory
  6. db.close() + db.open() — memory cleared

Expected behavior

After DROP INDEX, the in-memory ConcurrentHashMap entries for that index should be cleared. Alternatively, ArcadeDB should compact/remove stale entries automatically (standard LSM-tree compaction).

Actual behavior

Stale entries persist in the ConcurrentHashMap until the database is closed and reopened. DROP INDEX only removes the disk files; the in-memory map is untouched.

Heap histogram evidence (production)

jcmd <PID> GC.class_histogram

 num     #instances         #bytes  class name
   1:       9,353,958      299,326,656  java.util.concurrent.ConcurrentHashMap$Node
   2:       9,310,795      297,945,440  com.arcadedb.index.vector.VectorLocationIndex$VectorLocation
   4:       9,308,069      223,393,656  com.arcadedb.database.RID
   5:       9,316,716      149,067,456  java.lang.Integer

Total: ~970MB for 9.3M objects. Active vectors: ~4,029. Bloat ratio: ~2,300x.

Impact

  • Memory: ~970MB of unreachable-but-pinned heap for a 4,000-vector dataset
  • GC pressure: G1 old gen fills rapidly, causing sustained UNHEALTHY status (90%+ heap)
  • OOM: After weeks of 10-minute sync cycles, heap exhausts and the JVM crashes
  • Customer impact: Desktop app on 4GB VMs crashes after ~2-3 weeks of continuous operation

Workaround

Close and reopen the database after rebuilding vector indexes at startup:

db.command("sql", "DROP INDEX ${typeName}_embedding_idx")
db.command("sql", "CREATE INDEX ${typeName}_embedding_idx ON ${typeName} (embedding) LSM_VECTOR METADATA { ... }")
db.close()
db = DatabaseFactory(dbPath).open()  // Fresh in-memory state

Suggested fixes

  1. DROP INDEX should clear the in-memory index map — currently only removes disk files
  2. Automatic compaction for LSM_VECTOR — standard LSM-tree compaction would merge segments and remove tombstoned entries
  3. COMPACT INDEX SQL command — allow explicit compaction of vector indexes
  4. Don't load tombstoned entries on startup — when opening the database, skip stale entries when rebuilding the in-memory map

Related

Activity

  1. KyaniteSolutions commented on Jul 28, 2026

    @KyaniteSolutions
    Author

    Java reproduction script:

    import com.arcadedb.database.Database;
    import com.arcadedb.database.DatabaseFactory;
    import com.arcadedb.schema.Type;
    import com.arcadedb.schema.Schema;
    
    public class VectorIndexBloatRepro {
        public static void main(String[] args) throws Exception {
            String dbPath = "/tmp/arcadedb-vector-bloat-repro";
            new java.io.File(dbPath).deleteRecursively();
    
            DatabaseFactory factory = new DatabaseFactory(dbPath);
            Database db = factory.create();
    
            // 1. Create schema with embedding property + LSM_VECTOR index
            db.transaction(() -> {
                var type = db.getSchema().createVertexType("TestEntity");
                type.createProperty("id", Type.STRING);
                type.createProperty("embedding", Type.ARRAY_OF_FLOATS);
                type.createTypeIndex(Schema.INDEX_TYPE.LSM_TREE, true, "id");
            });
    
            db.command("sql", "CREATE INDEX TestEntity_embedding_idx ON TestEntity (embedding) " +
                "LSM_VECTOR METADATA { dimensions: 384, similarity: 'COSINE', quantization: 'INT8' }");
            System.out.println("Created LSM_VECTOR index");
    
            // 2. Store 1000 embeddings
            float[] fakeEmbedding = new float[384];
            for (int i = 0; i < 384; i++) fakeEmbedding[i] = i * 0.001f;
            for (int i = 1; i <= 1000; i++) {
                db.transaction(() -> {
                    var vertex = db.newVertex("TestEntity");
                    vertex.set("id", "entity_" + i);
                    vertex.set("embedding", fakeEmbedding);
                    vertex.save();
                });
            }
            System.out.println("Stored 1000 embeddings");
    
            // 3. Re-store same 1000 embeddings 10 times (simulating periodic sync cycles)
            for (int cycle = 1; cycle <= 10; cycle++) {
                for (int i = 1; i <= 1000; i++) {
                    db.transaction(() -> {
                        var rs = db.query("sql", "SELECT FROM TestEntity WHERE id = ?", "entity_" + i);
                        if (rs.hasNext()) {
                            var vertex = rs.next().getVertex().get().modify();
                            vertex.set("embedding", fakeEmbedding);
                            vertex.save();
                        }
                    });
                }
            }
            System.out.println("Re-stored 1000 embeddings x 10 cycles = 10,000 put() calls");
    
            // 4. Heap histogram — should show ~10,000 VectorLocation objects
            printHeapHistogram("After 10K put() calls");
    
            // 5. DROP INDEX + CREATE INDEX
            db.command("sql", "DROP INDEX TestEntity_embedding_idx");
            db.command("sql", "CREATE INDEX TestEntity_embedding_idx ON TestEntity (embedding) " +
                "LSM_VECTOR METADATA { dimensions: 384, similarity: 'COSINE', quantization: 'INT8' }");
            System.out.println("\nDropped + recreated index");
    
            // 6. Heap histogram — stale entries should still be in memory
            printHeapHistogram("After DROP + CREATE INDEX");
    
            // 7. Close + reopen
            db.close();
            db = factory.open();
            System.out.println("\nClosed + reopened database");
    
            // 8. Heap histogram — should now be clean
            printHeapHistogram("After db.close() + db.open()");
    
            // Verify data is intact
            var rs = db.query("sql", "SELECT count(*) AS cnt FROM TestEntity");
            System.out.println("\nVertex count after reopen: " + rs.next().getProperty("cnt"));
    
            db.close();
            new java.io.File(dbPath).deleteRecursively();
        }
    
        static void printHeapHistogram(String label) throws Exception {
            System.out.println("\n--- Heap histogram: " + label + " ---");
            System.gc();
            Thread.sleep(500);
            long pid = ProcessHandle.current().pid();
            Process p = new ProcessBuilder("jcmd", String.valueOf(pid), "GC.class_histogram")
                .redirectOutput(ProcessBuilder.Redirect.PIPE)
                .redirectError(ProcessBuilder.Redirect.DISCARD)
                .start();
            String output = new String(p.getInputStream().readAllBytes());
            p.waitFor();
            output.lines()
                .filter(l -> l.contains("VectorLocation") || l.contains("ConcurrentHashMap$Node"))
                .limit(3)
                .forEach(l -> System.out.println(l.trim()));
        }
    }

    To run: javac -cp arcadedb-engine-26.5.1.jar VectorIndexBloatRepro.java && java -cp .:arcadedb-engine-26.5.1.jar VectorIndexBloatRepro

  2. self-assigned this
    on Jul 29, 2026
  3. added theissue type on Jul 29, 2026
  4. added this to the 26.8.1 milestone on Jul 29, 2026
  5. lvca commented on Jul 29, 2026

    @lvca
    Member

    Fixed in #5521 - thanks for the heap histogram and the reproduction script, they made this quick to pin down.

    What was actually wrong. Your diagnosis was right about the symptom and there were two causes behind it:

    1. A tombstoned vector id kept its whole VectorLocation resident - the location, its RID, the map node and a slot in the reverse index, ~90 bytes - purely to carry a deleted boolean. Nothing ever reads a dead id's offset again, so that memory bought nothing. A superseded id is now one bit in a compact set: ~1.2MB for the 9.3M ids that were costing you ~970MB. It also means opening the database no longer pulls every tombstoned page entry into memory.

    2. Each of your updates was being indexed twice - once while the commit serialized the record, once replaying the operations queued on the transaction. A normal LSM index collapses that onto the same key, but a vector put mints a new vector id every time, so every re-embedding cycle burned two ids and wrote two entries plus two tombstones per vertex. That is why your bloat ratio was roughly double what the arithmetic suggested. Updates now mint exactly one.

    Measured on 100 vertices re-embedded 10 times: 2100 resident locations before, 100 after, and a reopen loads 100 instead of 2100.

    Your workaround is no longer needed. DROP INDEX did already clear the in-memory map (there is a regression test for it now), but the close-and-reopen dance was doing the real work of dropping the accumulated tombstones. That accumulation is gone.

    On the disk side, which the issue did not mention but your workload will hit. The index file grows with every write and nothing reclaimed it, so after weeks of 10-minute sync cycles you would have had a multi-GB index for ~4K live vectors. COMPACT INDEX <name> is the reclaim path - and while verifying it for this fix we found it was broken in four ways (it resurrected deleted vectors, duplicated them on a second run, never dropped the file it replaced, and left the index unreadable after a restart). All four are fixed in the same PR. On the test workload it takes 9 pages / 2304KB / 10500 entries down to 1 page / 256KB / 500 entries.

    Compaction is not automatic - it stays an explicit operation for now - so for a workload like yours the recommendation is to run COMPACT INDEX on the vector index periodically, e.g. after a sync cycle, instead of dropping and recreating the index at startup.

  6. added a commit that references this issue on Jul 29, 2026
  7. added 4 commits that reference this issue on Jul 30, 2026
  8. added 2 commits that reference this issue on Aug 7, 2026
  9. added 3 commits that reference this issue on Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions