Skip to content

Bug: Persistent FTS index on an empty node table causes hash_index.cpp assertion after reopen #464

Closed

Description

@sify21

Ladybug version

Rust crate: lbug = 0.16.1

What operating system are you using?

Ubuntu 24.04

What happened?

Creating a persistent FTS index on an empty node table succeeds, but after closing the process and reopening the
database from a new process, even a normal MATCH query fails with an internal assertion:

Assertion failed in file ".../lbug-src/src/storage/index/hash_index.cpp" on line 483:
hashIndexStorageInfo.overflowHeaderPage == INVALID_PAGE_IDX

This is different from #438: LOAD EXTENSION FTS succeeds when the Rust host binary is built with -rdynamic. The
failure happens after reopening a database that contains an FTS index created on an empty node table.

Are there known steps to reproduce?

Minimal Rust Reproduction

Cargo.toml:

[package]
name = "lbug_empty_fts_repro"
version = "0.1.0"
edition = "2021"
build = "build.rs"

[dependencies]
lbug = "0.16.1"

build.rs:

fn main() {
    // Needed for LadybugDB dynamic extensions to resolve symbols from the Rust host binary.
    println!("cargo:rustc-link-arg=-rdynamic");
}

src/main.rs:

use std::{env, process};

fn main() {
    if let Err(err) = run() {
        eprintln!("{err}");
        process::exit(1);
    }
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let mut args = env::args().skip(1);
    let db_path = args.next().unwrap_or_else(|| "/tmp/lbug-empty-fts.db".to_string());
    let mode = args.next().unwrap_or_else(|| "create-empty-index".to_string());

    let db = lbug::Database::new(&db_path, lbug::SystemConfig::default())?;
    let conn = lbug::Connection::new(&db)?;

    match mode.as_str() {
        "create-empty-index" => {
            conn.query(
                "CREATE NODE TABLE IF NOT EXISTS TestNode (
                    id STRING,
                    name STRING,
                    body STRING,
                    PRIMARY KEY(id)
                );",
            )?;
            conn.query("INSTALL FTS;")?;
            conn.query("LOAD EXTENSION FTS;")?;
            conn.query(
                "CALL CREATE_FTS_INDEX(
                    'TestNode',
                    'test_node_fts',
                    ['name', 'body'],
                    stemmer := 'porter'
                );",
            )?;
            conn.query("CHECKPOINT;")?;
            println!("created FTS index on empty table");
        }

        "reopen-query" => {
            conn.query("LOAD EXTENSION FTS;")?;
            let result = conn.query("MATCH (n:TestNode) RETURN n.id LIMIT 5;")?;
            println!("{result}");
        }

        "create-nonempty-index" => {
            conn.query(
                "CREATE NODE TABLE IF NOT EXISTS TestNode (
                    id STRING,
                    name STRING,
                    body STRING,
                    PRIMARY KEY(id)
                );",
            )?;
            conn.query("CREATE (:TestNode {id: 'n1', name: 'hello', body: 'hello world'});")?;
            conn.query("INSTALL FTS;")?;
            conn.query("LOAD EXTENSION FTS;")?;
            conn.query(
                "CALL CREATE_FTS_INDEX(
                    'TestNode',
                    'test_node_fts',
                    ['name', 'body'],
                    stemmer := 'porter'
                );",
            )?;
            conn.query("CHECKPOINT;")?;
            println!("created FTS index on non-empty table");
        }

        other => {
            return Err(format!("unknown mode: {other}").into());
        }
    }

    Ok(())
}

Steps to Reproduce

rm -f /tmp/lbug-empty-fts.db

cargo run -- /tmp/lbug-empty-fts.db create-empty-index
cargo run -- /tmp/lbug-empty-fts.db reopen-query

Actual Result

The second command fails with:

Assertion failed in file ".../lbug-src/src/storage/index/hash_index.cpp" on line 483:
hashIndexStorageInfo.overflowHeaderPage == INVALID_PAGE_IDX

Expected Result

Creating an FTS index on an empty table should either:

  • be supported and reopen cleanly,
  • fail gracefully when the index is created, or
  • be ignored/deferred until rows exist.

It should not leave the database in a state that later triggers an internal assertion after reopen.

Control Case

If the table contains at least one row before creating the FTS index, reopening succeeds:

rm -f /tmp/lbug-empty-fts.db

cargo run -- /tmp/lbug-empty-fts.db create-nonempty-index
cargo run -- /tmp/lbug-empty-fts.db reopen-query

This suggests the issue is specific to persistent FTS indexes created on empty node tables.

Relation to #438

This is related to Rust embedding and FTS, but it is not the same failure mode:

Activity

  1. adsharma commented on May 7, 2026

    @adsharma
    Contributor

    I can't repro with:

    lbug test.db < create_empty_fts.cypher
    lbug test.db < reopen_query.cypher
    

    Also ran your rust repro 3 times on ubuntu 24.04. Doesn't repro for me.

  2. adsharma commented on May 7, 2026

    @adsharma
    Contributor
  3. sify21 commented on May 8, 2026

    @sify21
    Author

    @adsharma I forgot to mention that I need to build lbug from source, or else it will report a lot of rust-lld error

    .cargo/config.toml:

    [env]
    # The lbug 0.16.1 prebuilt static archive does not include all native
    # third-party dependencies needed at link time on Linux (for example
    # roaring_bitmap and antlr4_cypher). Building from bundled source lets the
    # crate link its complete dependency set.
    LBUG_BUILD_FROM_SOURCE = "1"
    

    In my environment it reproduces each time

    Image

    My rust version is cargo 1.94.1 (29ea6fb6a 2026-03-24)

  4. adsharma commented on May 8, 2026

    @adsharma
    Contributor

    Yes - I built from source as well. Tested on both macos/arm64 and ubuntu 24.04/x64.

    cargo 1.93.1 (083ac5135 2025-12-15)

    Can you build a shell in your env (make shell in the ladybug source tree) and see if it repros?

    If shell doesn't repro, but rust repros, it narrows the problems to rust bindings.

  5. sify21 commented on May 8, 2026

    @sify21
    Author

    @adsharma Running lbug in build/release/tools/shell doesn't report error. The shell is built using tag v0.16.1

    ❯ ./lbug /tmp/lbug-empty-fts.db < create_empty_fts.txt
    Opening the database at path: /tmp/lbug-empty-fts.db in read-write mode.
    Enter ":help" for usage hints.
    ┌──────────────────────────────────┐
    │ result                           │
    │ STRING                           │
    ├──────────────────────────────────┤
    │ Table TestNode has been created. │
    └──────────────────────────────────┘
    (1 tuple)
    (1 column)
    Time: 3.37ms (compiling), 9.28ms (executing)
    ┌────────────────────────────────────────────────────────────────────────────┐
    │ result                                                                     │
    │ STRING                                                                     │
    ├────────────────────────────────────────────────────────────────────────────┤
    │ Extension: fts is already installed.
    To update it, you can run: UPDATE fts. │
    └────────────────────────────────────────────────────────────────────────────┘
    (1 tuple)
    (1 column)
    Time: 0.15ms (compiling), 6.36ms (executing)
    ┌─────────────────────────────────┐
    │ result                          │
    │ STRING                          │
    ├─────────────────────────────────┤
    │ Extension: fts has been loaded. │
    └─────────────────────────────────┘
    (1 tuple)
    (1 column)
    Time: 0.18ms (compiling), 9.02ms (executing)
    ┌───────────────────────────────────────┐
    │ result                                │
    │ STRING                                │
    ├───────────────────────────────────────┤
    │ Index test_node_fts has been created. │
    └───────────────────────────────────────┘
    (1 tuple)
    (1 column)
    Time: 233.00ms (compiling), 426.24ms (executing)
    (0 tuples)
    (0 columns)
    Time: 0.09ms (compiling), 21.83ms (executing)
    
    ❯ ./lbug /tmp/lbug-empty-fts.db < reopen_query.txt    
    Opening the database at path: /tmp/lbug-empty-fts.db in read-write mode.
    Enter ":help" for usage hints.
    ┌─────────────────────────────────┐
    │ result                          │
    │ STRING                          │
    ├─────────────────────────────────┤
    │ Extension: fts has been loaded. │
    └─────────────────────────────────┘
    (1 tuple)
    (1 column)
    Time: 3.01ms (compiling), 7.81ms (executing)
    ┌────────┐
    │ n.id   │
    │ STRING │
    ├────────┤
    └────────┘
    (0 tuples)
    (1 column)
    Time: 8.33ms (compiling), 0.77ms (executing)
  6. adsharma commented on May 18, 2026

    @adsharma
    Contributor

    Merged LadybugDB/extensions#1. Concurrent access to the unordered_map could be the root cause. Please retest when 0.17.x is released.

  7. adsharma commented on Jun 16, 2026

    @adsharma
    Contributor

    @sify21 did you get a chance to retest? Merged a couple of FTS related fixes into main.

  8. kory-io commented on Sep 28, 2026

    @kory-io
    Contributor

    I think I found the root cause. It also explains why this reproduces in some builds but not others.

    Why only some builds fail. The failing check is a DASSERT (hash_index.cpp:537 on current main). DASSERT is only compiled in when RUNTIME_CHECKS is defined or NDEBUG is not (common/assert.h), and a failure throws an InternalException.

    • A release shell build compiles the check out, so make release + the shell does not reproduce.
    • The Rust crate's build.rs enables runtime checks for its debug and relwithdebinfo builds, which matches the from-source cargo build reproducing every time.

    In release builds the state below is still written. It just goes unchecked.

    Root cause: PrimaryKeyIndex::checkpoint (hash_index.cpp:765-780) sets the two header pointers under different conditions.

    • firstHeaderPage is only assigned in writeHeaders, which runs under if (indexChanged).
    • updateOverflowHeaderPageIfNeeded runs whenever an overflow file exists (a STRING primary key), regardless of indexChanged, and sets overflowHeaderPage if it is unset.

    When a checkpoint processes the index of a node table with zero rows, indexChanged is false. So it persists firstHeaderPage == INVALID_PAGE_IDX together with a valid overflowHeaderPage. On the next open, the constructor takes the firstHeaderPage == INVALID_PAGE_IDX branch and asserts overflowHeaderPage == INVALID_PAGE_IDX, which is the combination the checkpoint just wrote.

    Minimal repro without FTS, run with a shell built from main (8caf03b) with -DENABLE_RUNTIME_CHECKS=TRUE:

    CREATE NODE TABLE t(id STRING PRIMARY KEY);
    ALTER TABLE t ADD v INT64;
    CHECKPOINT;

    Reopening the database, even for MATCH (n:t) RETURN count(*);, fails with Assertion failed ... hash_index.cpp on line 537: hashIndexStorageInfo.overflowHeaderPage == INVALID_PAGE_IDX.

    Controls on the same build, all of which reopen cleanly:

    • one row inserted before the ALTER;
    • an INT64 primary key (no overflow file);
    • zero rows with a plain CHECKPOINT and no ALTER (that checkpoint does not reach this index).

    I haven't traced the FTS path. Presumably creating the FTS index on the empty table, like the ALTER, is what makes the checkpoint write this empty index.

    Possible fixes:

    • assign both pointers under the same condition, or
    • treat "overflow header set, first header unset" as a valid empty-index state and relax the assertion.

    Happy to open a PR for whichever you prefer.

  9. adsharma commented on Sep 28, 2026

    @adsharma
    Contributor

    Please send a pr with: assign both pointers under the same condition

  10. added a commit that references this issue on Sep 28, 2026
    08915c1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions