Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions pkg/cypher/bug_schema_async_routing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package cypher

import (
"context"
"os"
"testing"

"github.com/orneryd/nornicdb/pkg/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestBug_SchemaCommandsWithAsyncEngine reproduces the bug where CREATE CONSTRAINT
// and CREATE INDEX queries fail with "invalid label name: Node)" when an AsyncEngine
// is active (i.e. production deployments).
//
// Root cause: tryAsyncCreateNodeBatch intercepts all queries starting with CREATE
// before the schema handler runs. Without an async engine (unit tests / MemoryEngine)
// the early-return at "if engines.asyncEngine != nil" skips tryAsyncCreateNodeBatch,
// masking the bug. In production the async engine is always active, causing schema
// commands to be parsed as node CREATE patterns.
//
// Reported via: Mimir (https://github.com/orneryd/Mimir) integration — schema
// initialization failed on every startup when NornicDB was backed by an AsyncEngine.
//
// Environment: macOS 15 (Apple Silicon M1), NornicDB arm64-metal-bge-heimdall image,
// Mimir MCP server v4.1, Docker with Colima (aarch64, VZ).
func TestBug_SchemaCommandsWithAsyncEngine(t *testing.T) {
// Setup full production-equivalent stack: BadgerEngine -> WALEngine -> AsyncEngine.
// This is the critical difference from MemoryEngine-based tests — the AsyncEngine
// being present is what triggers tryAsyncCreateNodeBatch and exposes the bug.
tmpDir, err := os.MkdirTemp("", "schema_async_routing_test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

badger, err := storage.NewBadgerEngine(tmpDir)
require.NoError(t, err)
defer badger.Close()

wal, err := storage.NewWAL(tmpDir+"/wal", nil)
require.NoError(t, err)
defer wal.Close()

walEngine := storage.NewWALEngine(badger, wal)
asyncEngine := storage.NewAsyncEngine(walEngine, nil)
defer asyncEngine.Close()

store := storage.NewNamespacedEngine(asyncEngine, "test")
exec := NewStorageExecutor(store)
ctx := context.Background()

// These are the exact schema queries Mimir v4.1 sends during GraphManager
// initialization (pkg/managers/GraphManager.ts). Before the fix each query
// returned: invalid label name: "Node)" (must be alphanumeric starting with
// letter or underscore) because tryAsyncCreateNodeBatch parsed FOR (n:Node)
// as a node CREATE pattern and captured "Node)" as the label.
tests := []struct {
name string
query string
}{
{
name: "CREATE CONSTRAINT node_id_unique",
query: `
CREATE CONSTRAINT node_id_unique IF NOT EXISTS
FOR (n:Node) REQUIRE n.id IS UNIQUE
`,
},
{
name: "CREATE FULLTEXT INDEX node_search",
query: `
CREATE FULLTEXT INDEX node_search IF NOT EXISTS
FOR (n:Node) ON EACH [n.properties]
`,
},
{
name: "CREATE INDEX node_type",
query: `
CREATE INDEX node_type IF NOT EXISTS
FOR (n:Node) ON (n.type)
`,
},
{
name: "CREATE CONSTRAINT watch_config_id_unique",
query: `
CREATE CONSTRAINT watch_config_id_unique IF NOT EXISTS
FOR (w:WatchConfig) REQUIRE w.id IS UNIQUE
`,
},
{
name: "CREATE INDEX watch_config_path",
query: `
CREATE INDEX watch_config_path IF NOT EXISTS
FOR (w:WatchConfig) ON (w.path)
`,
},
{
name: "CREATE INDEX file_path",
query: `
CREATE INDEX file_path IF NOT EXISTS
FOR (f:File) ON (f.path)
`,
},
{
name: "CREATE FULLTEXT INDEX file_metadata_search",
query: `
CREATE FULLTEXT INDEX file_metadata_search IF NOT EXISTS
FOR (f:File) ON EACH [f.path, f.name, f.language]
`,
},
{
name: "CREATE VECTOR INDEX node_embedding_index",
query: `
CREATE VECTOR INDEX node_embedding_index IF NOT EXISTS
FOR (n:Node) ON (n.embedding)
OPTIONS {indexConfig: {vector.dimensions: 1024}}
`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := exec.Execute(ctx, tt.query, nil)
assert.NoError(t, err, "schema command should succeed with async engine active")
})
}
}
9 changes: 7 additions & 2 deletions pkg/cypher/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -790,10 +790,15 @@ func (e *StorageExecutor) tryAsyncCreateNodeBatch(ctx context.Context, cypher st
if !strings.HasPrefix(upper, "CREATE") {
return nil, nil, false
}
// System commands (CREATE DATABASE / COMPOSITE DATABASE / ALIAS) must not be handled here
// System commands and schema commands must not be handled here — route to executeSchemaCommand instead
if findMultiWordKeywordIndex(cypher, "CREATE", "DATABASE") == 0 ||
findMultiWordKeywordIndex(cypher, "CREATE", "COMPOSITE DATABASE") == 0 ||
findMultiWordKeywordIndex(cypher, "CREATE", "ALIAS") == 0 {
findMultiWordKeywordIndex(cypher, "CREATE", "ALIAS") == 0 ||
findMultiWordKeywordIndex(cypher, "CREATE", "CONSTRAINT") == 0 ||
findMultiWordKeywordIndex(cypher, "CREATE", "INDEX") == 0 ||
findMultiWordKeywordIndex(cypher, "CREATE", "FULLTEXT") == 0 ||
findMultiWordKeywordIndex(cypher, "CREATE", "VECTOR") == 0 ||
findMultiWordKeywordIndex(cypher, "CREATE", "RANGE") == 0 {
return nil, nil, false
}
for _, keyword := range []string{
Expand Down
Loading