Skip to content

Stale database handles after an aborted transaction are silently accepted #73

Description

@qdequele

Context

In LMDB (and in heed, the typed Rust wrapper around LMDB that Meilisearch uses), opening a named database inside a transaction returns a handle. LMDB's contract for that handle is: it is private to the transaction that opened it until that transaction commits; if the transaction aborts instead, the handle is closed automatically. Internally, LMDB's mdb_txn_end implements this by clearing the handle's validity flag and bumping its sequence number in an env-wide table on abort. Reusing the handle afterward fails LMDB's validity check and returns EINVAL. A related case: explicitly deleting a database (mdb_drop with delete=true) also closes its handle env-wide, and a later transaction abort does not undo that closure.

ZeroDB's Database type (crates/zerodb-core/src/rotxn.rs) has no equivalent bookkeeping — it is a plain value (a name plus an internal selector), not an entry in an env-wide table carrying a validity flag and generation counter. It therefore has no way to go stale: reusing a Database handle whose creating transaction was aborted silently works instead of returning an error.

This is tracked in docs/DIVERGENCES.md — the repo's catalog of sanctioned, known behavior differences from LMDB — as an entry describing exactly this behavior, currently marked critical by maintainer directive (2026-07-22) and still proposed, not yet approved. It surfaced on 2026-07-20 during fuzz testing that runs paired zerodb/LMDB adapters through randomized operation sequences, part of the differential test harness that compares zerodb against C LMDB (crates/zerodb-oracle). The harness's own bookkeeping bug that first exposed the case has since been fixed, with regression coverage added in crates/zerodb-oracle/tests/dbi_handle_lifetime.rs.

Task

Decide, and write a short design document (ADR) in docs/adr/ that must be approved before implementation, whether ZeroDB should:

  1. add an env-level handle registry so each Database handle carries a generation counter that every operation validates (a real engine design change), or
  2. only reproduce LMDB's rejection at the heed-zerodb adapter boundary, leaving the core engine as-is.

No code should change until a maintainer approves the design document — this changes observable error behavior, not just an internal detail.

Why it matters

No current consumer is affected today: milli (Meilisearch's indexing core) and hannoy (Meilisearch's HNSW vector index) always commit the transactions in which they create their databases, so neither ever reuses a handle from an aborted one. But this is the one known case where usage LMDB explicitly defines as invalid — reusing a handle after its creating transaction aborted — is silently accepted by ZeroDB instead of raising an error. LMDB turns that consumer bug into an immediate, loud EINVAL; ZeroDB currently masks it. Left unresolved, this both hides a class of bug in any future consumer code and is a known gap versus the LMDB behavior ZeroDB is meant to match.

Risk

Low for existing consumers — nothing in milli or hannoy exercises this path today. But the fix itself changes the observable error semantics of handle reuse, which is why it needs the design document and maintainer sign-off first rather than a quick patch.

See also: docs/DIVERGENCES.md — full divergence tracking table; crates/zerodb-oracle/tests/dbi_handle_lifetime.rs — regression test coverage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiNew engine/adapter API surfacebreaking:apiChanges public API shape or observable semanticscriticalCritical: must-track gap vs LMDB paritydivergenceSanctioned behavior divergence vs LMDB (docs/DIVERGENCES.md)needs-adrRequires a short approved design doc (ADR in docs/adr/) before implementationsize:MMedium: a few days

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions