From e926298a7d1942f48677a689363bd0b76c996c76 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Fri, 22 May 2026 10:43:59 +0200 Subject: [PATCH 1/3] Clean-up tests --- test/common.jl | 14 ++- test/cur.jl | 218 +++++++++++++++++---------------- test/dbi.jl | 282 +++++++++++++++++++++---------------------- test/dupsort.jl | 112 ++++++++--------- test/env.jl | 285 ++++++++++++++++++++++++++++---------------- test/integration.jl | 182 ++++++++++++++-------------- test/liblmdb.jl | 102 ++++++++-------- 7 files changed, 637 insertions(+), 558 deletions(-) diff --git a/test/common.jl b/test/common.jl index 0683c80..7f00247 100644 --- a/test/common.jl +++ b/test/common.jl @@ -1,9 +1,13 @@ using LMDB using Test - @test LMDB.version()[1] >= v"0.9.15" +@testset "common" begin - # LMDBError - ex = LMDBError(Cint(0)) - @test_throws LMDBError throw(ex) - @test ex.code == 0 +@test LMDB.version()[1] >= v"0.9.15" + +# LMDBError +ex = LMDBError(Cint(0)) +@test_throws LMDBError throw(ex) +@test ex.code == 0 + +end # @testset "common" diff --git a/test/cur.jl b/test/cur.jl index c4c2a84..8a35b65 100644 --- a/test/cur.jl +++ b/test/cur.jl @@ -1,15 +1,14 @@ -module LMDB_CUR - using LMDB - using Test +using LMDB +using Test - const dbname = "testdb" - key = 10 - val = "key value is " +@testset "Cursor" begin - # Create dir - mkdir(dbname) +key = 10 +val = "key value is " - # Procedural style +# Procedural style + block style smoke test, exercising cursor put!/walk +# round-trip and the parent accessors. +mktempdir() do dbname env = create() try open(env, dbname) @@ -52,122 +51,121 @@ module LMDB_CUR end end end +end + +# Cursor positioning + walk primitives. +mktempdir() do dir + environment(dir) do env + start(env) do txn + open(txn) do dbi + LMDB.put!(txn, dbi, "a", "1") + LMDB.put!(txn, dbi, "b", "2") + LMDB.put!(txn, dbi, "c", "3") + + LMDB.open(txn, dbi) do cur + @test LMDB.seek!(cur, String) == "a" + @test LMDB.value(cur, String) == "1" + @test LMDB.key(cur, String) == "a" + @test LMDB.item(cur, String, String) == ("a" => "1") + + @test LMDB.next!(cur, String) == "b" + @test LMDB.value(cur, String) == "2" + + @test LMDB.seek_last!(cur, String) == "c" + @test LMDB.prev!(cur, String) == "b" + + @test LMDB.seek!(cur, "a", String) == "a" + @test LMDB.seek!(cur, "missing", String) === nothing + + @test LMDB.seek_range!(cur, "ab", String) == "b" + @test LMDB.seek_range!(cur, "z", String) === nothing + + # walk over everything + ks = String[] + LMDB.walk(cur) do k_ref, _ + push!(ks, read(LMDB.MDBValueIO(k_ref[]), String)) + end + @test ks == ["a", "b", "c"] + + # walk from a starting key + ks2 = String[] + LMDB.walk(cur; from="b") do k_ref, _ + push!(ks2, read(LMDB.MDBValueIO(k_ref[]), String)) + end + @test ks2 == ["b", "c"] - # Remove db dir - rm(dbname, recursive=true) - - # Cursor positioning + walk primitives. - mktempdir() do dir - environment(dir) do env - start(env) do txn - open(txn) do dbi - LMDB.put!(txn, dbi, "a", "1") - LMDB.put!(txn, dbi, "b", "2") - LMDB.put!(txn, dbi, "c", "3") - - LMDB.open(txn, dbi) do cur - @test LMDB.seek!(cur, String) == "a" - @test LMDB.value(cur, String) == "1" - @test LMDB.key(cur, String) == "a" - @test LMDB.item(cur, String, String) == ("a" => "1") - - @test LMDB.next!(cur, String) == "b" - @test LMDB.value(cur, String) == "2" - - @test LMDB.seek_last!(cur, String) == "c" - @test LMDB.prev!(cur, String) == "b" - - @test LMDB.seek!(cur, "a", String) == "a" - @test LMDB.seek!(cur, "missing", String) === nothing - - @test LMDB.seek_range!(cur, "ab", String) == "b" - @test LMDB.seek_range!(cur, "z", String) === nothing - - # walk over everything - ks = String[] - LMDB.walk(cur) do k_ref, _ - push!(ks, read(LMDB.MDBValueIO(k_ref[]), String)) - end - @test ks == ["a", "b", "c"] - - # walk from a starting key - ks2 = String[] - LMDB.walk(cur; from="b") do k_ref, _ - push!(ks2, read(LMDB.MDBValueIO(k_ref[]), String)) - end - @test ks2 == ["b", "c"] - - # walk from a key past the last entry — no callbacks. - ks3 = String[] - LMDB.walk(cur; from="z") do k_ref, _ - push!(ks3, read(LMDB.MDBValueIO(k_ref[]), String)) - end - @test isempty(ks3) - - # typed walk: each ref decoded via read(MDBValueIO, K) - # / read(MDBValueIO, V). - kv = Pair{String, String}[] - LMDB.walk(cur, String, String) do k, v - push!(kv, k => v) - end - @test kv == ["a" => "1", "b" => "2", "c" => "3"] - - # typed walk respects the false-stops contract. - seen = Pair{String, String}[] - LMDB.walk(cur, String, String) do k, v - push!(seen, k => v) - k == "b" ? false : nothing - end - @test seen == ["a" => "1", "b" => "2"] + # walk from a key past the last entry — no callbacks. + ks3 = String[] + LMDB.walk(cur; from="z") do k_ref, _ + push!(ks3, read(LMDB.MDBValueIO(k_ref[]), String)) end + @test isempty(ks3) + + # typed walk: each ref decoded via read(MDBValueIO, K) + # / read(MDBValueIO, V). + kv = Pair{String, String}[] + LMDB.walk(cur, String, String) do k, v + push!(kv, k => v) + end + @test kv == ["a" => "1", "b" => "2", "c" => "3"] + + # typed walk respects the false-stops contract. + seen = Pair{String, String}[] + LMDB.walk(cur, String, String) do k, v + push!(seen, k => v) + k == "b" ? false : nothing + end + @test seen == ["a" => "1", "b" => "2"] end end end end +end - # seek!/next! on an empty database returns nothing. - mktempdir() do dir - environment(dir) do env - start(env) do txn - open(txn) do dbi - LMDB.open(txn, dbi) do cur - @test LMDB.seek!(cur, String) === nothing - @test LMDB.seek_last!(cur, String) === nothing - @test LMDB.seek!(cur, "x", String) === nothing - @test LMDB.seek_range!(cur, "x", String) === nothing - - ks = String[] - LMDB.walk(cur) do k_ref, _ - push!(ks, read(LMDB.MDBValueIO(k_ref[]), String)) - end - @test isempty(ks) +# seek!/next! on an empty database returns nothing. +mktempdir() do dir + environment(dir) do env + start(env) do txn + open(txn) do dbi + LMDB.open(txn, dbi) do cur + @test LMDB.seek!(cur, String) === nothing + @test LMDB.seek_last!(cur, String) === nothing + @test LMDB.seek!(cur, "x", String) === nothing + @test LMDB.seek_range!(cur, "x", String) === nothing + + ks = String[] + LMDB.walk(cur) do k_ref, _ + push!(ks, read(LMDB.MDBValueIO(k_ref[]), String)) end + @test isempty(ks) end end end end +end - # Cursor.delete!: removes the entry the cursor is on; LMDB advances to - # the next entry. Throws on an unpositioned cursor (EINVAL), unlike the - # txn-based `delete!(txn, dbi, key)` which is Bool-returning on - # MDB_NOTFOUND. - mktempdir() do dir - environment(dir) do env - start(env) do txn - open(txn) do dbi - LMDB.put!(txn, dbi, "a", "1") - LMDB.put!(txn, dbi, "b", "2") - - LMDB.open(txn, dbi) do cur - @test LMDB.seek!(cur, "a", String) == "a" - LMDB.delete!(cur) # removes "a", cursor now on "b" - LMDB.delete!(cur) # removes "b" - @test_throws LMDBError LMDB.delete!(cur) # no live entry - end - @test LMDB.tryget(txn, dbi, "a", String) === nothing - @test LMDB.tryget(txn, dbi, "b", String) === nothing +# Cursor.delete!: removes the entry the cursor is on; LMDB advances to +# the next entry. Throws on an unpositioned cursor (EINVAL), unlike the +# txn-based `delete!(txn, dbi, key)` which is Bool-returning on +# MDB_NOTFOUND. +mktempdir() do dir + environment(dir) do env + start(env) do txn + open(txn) do dbi + LMDB.put!(txn, dbi, "a", "1") + LMDB.put!(txn, dbi, "b", "2") + + LMDB.open(txn, dbi) do cur + @test LMDB.seek!(cur, "a", String) == "a" + LMDB.delete!(cur) # removes "a", cursor now on "b" + LMDB.delete!(cur) # removes "b" + @test_throws LMDBError LMDB.delete!(cur) # no live entry end + @test LMDB.tryget(txn, dbi, "a", String) === nothing + @test LMDB.tryget(txn, dbi, "b", String) === nothing end end end end + +end # @testset "Cursor" diff --git a/test/dbi.jl b/test/dbi.jl index 17c9631..0bdb87c 100644 --- a/test/dbi.jl +++ b/test/dbi.jl @@ -1,170 +1,162 @@ -module LMDB_DBI - using LMDB - using Test +using LMDB +using Test - const dbname = "testdb" - key = 10 - val = "key value is " +@testset "DBI" begin - # Create dir - mkdir(dbname) - try +key = 10 +val = "key value is " - # Procedural style - env = create() - try - open(env, dbname) - txn = start(env) - dbi = open(txn) - put!(txn, dbi, key+1, val*string(key+1)) - put!(txn, dbi, key, val*string(key)) - put!(txn, dbi, key+2, key+2) - put!(txn, dbi, key+3, [key, key+1, key+2]) - @test isopen(txn) - commit(txn) - @test !isopen(txn) - close(env, dbi) - @test !isopen(dbi) - finally - close(env) - end - @test !isopen(env) - - # Block style - create() do env - set!(env, LMDB.MDB_NOSYNC) - open(env, dbname) - start(env) do txn - open(txn, flags = Cuint(LMDB.MDB_REVERSEKEY)) do dbi - k = key - value = get(txn, dbi, k, String) - println("Got value for key $(k): $(value)") - @test value == val*string(k) - delete!(txn, dbi, k) - k += 1 - value = get(txn, dbi, k, String) - println("Got value for key $(k): $(value)") - @test value == val*string(k) - delete!(txn, dbi, k, value) - @test_throws LMDBError get(txn, dbi, k, String) - k += 1 - value = get(txn, dbi, k, Int) - println("Got value for key $(k): $(value)") - @test value == k - k += 1 - value = get(txn, dbi, k, Vector{Int}) - println("Got value for key $(k): $(value)") - @test value == [key, key+1, key+2] - end +# Procedural style + block style smoke test, exercising String, Int, and +# Vector{Int} round-trips through put!/get/delete!. +mktempdir() do dbname + env = create() + try + open(env, dbname) + txn = start(env) + dbi = open(txn) + put!(txn, dbi, key+1, val*string(key+1)) + put!(txn, dbi, key, val*string(key)) + put!(txn, dbi, key+2, key+2) + put!(txn, dbi, key+3, [key, key+1, key+2]) + @test isopen(txn) + commit(txn) + @test !isopen(txn) + close(env, dbi) + @test !isopen(dbi) + finally + close(env) + end + @test !isopen(env) + + # Block style + create() do env + set!(env, LMDB.MDB_NOSYNC) + open(env, dbname) + start(env) do txn + open(txn, flags = Cuint(LMDB.MDB_REVERSEKEY)) do dbi + k = key + value = get(txn, dbi, k, String) + @test value == val*string(k) + delete!(txn, dbi, k) + k += 1 + value = get(txn, dbi, k, String) + @test value == val*string(k) + delete!(txn, dbi, k, value) + @test_throws LMDBError get(txn, dbi, k, String) + k += 1 + value = get(txn, dbi, k, Int) + @test value == k + k += 1 + value = get(txn, dbi, k, Vector{Int}) + @test value == [key, key+1, key+2] end end - - finally - rm(dbname, recursive=true) end +end - # tryget / get-with-default / stat(txn, dbi) — fresh env so the entry - # count is deterministic. - mktempdir() do dir - environment(dir) do env - start(env) do txn - open(txn) do dbi - LMDB.put!(txn, dbi, "k1", "v1") - LMDB.put!(txn, dbi, "k2", "v2") - - @test LMDB.tryget(txn, dbi, "k1", String) == "v1" - @test LMDB.tryget(txn, dbi, "missing", String) === nothing - @test get(txn, dbi, "k2", String, "fallback") == "v2" - @test get(txn, dbi, "missing", String, "fallback") == "fallback" - - s = LMDB.stat(txn, dbi) - @test s isa NamedTuple - @test s.entries == 2 - @test s.psize > 0 - end +# tryget / get-with-default / stat(txn, dbi) — fresh env so the entry +# count is deterministic. +mktempdir() do dir + environment(dir) do env + start(env) do txn + open(txn) do dbi + LMDB.put!(txn, dbi, "k1", "v1") + LMDB.put!(txn, dbi, "k2", "v2") + + @test LMDB.tryget(txn, dbi, "k1", String) == "v1" + @test LMDB.tryget(txn, dbi, "missing", String) === nothing + @test get(txn, dbi, "k2", String, "fallback") == "v2" + @test get(txn, dbi, "missing", String, "fallback") == "fallback" + + s = LMDB.stat(txn, dbi) + @test s isa NamedTuple + @test s.entries == 2 + @test s.psize > 0 end end end +end - # put_reserved!: callback-style MDB_RESERVE write. - mktempdir() do dir - environment(dir) do env - start(env) do txn - open(txn) do dbi - # Write a 16-byte value where bytes 0..7 are a UInt64 - # header and bytes 8..15 are payload. The buffer hands - # back is the LMDB-allocated mmap page; we fill it - # in place — no intermediate Vector. - LMDB.put_reserved!(txn, dbi, "framed", 16) do buf - @test buf isa Vector{UInt8} - @test length(buf) == 16 - unsafe_store!(Ptr{UInt64}(pointer(buf)), - htol(UInt64(0xdeadbeef))) - for i in 1:8 - buf[8 + i] = UInt8(i) - end - end - raw = LMDB.tryget(txn, dbi, "framed", Vector{UInt8}) - @test length(raw) == 16 - @test ltoh(reinterpret(UInt64, raw[1:8])[1]) == - UInt64(0xdeadbeef) - @test raw[9:16] == UInt8[1, 2, 3, 4, 5, 6, 7, 8] - - # Return value: whatever the callback returns. - rv = LMDB.put_reserved!(txn, dbi, "rv", 4) do buf - fill!(buf, 0xab) - :sentinel +# put_reserved!: callback-style MDB_RESERVE write. +mktempdir() do dir + environment(dir) do env + start(env) do txn + open(txn) do dbi + # Write a 16-byte value where bytes 0..7 are a UInt64 + # header and bytes 8..15 are payload. The buffer hands + # back is the LMDB-allocated mmap page; we fill it + # in place — no intermediate Vector. + LMDB.put_reserved!(txn, dbi, "framed", 16) do buf + @test buf isa Vector{UInt8} + @test length(buf) == 16 + unsafe_store!(Ptr{UInt64}(pointer(buf)), + htol(UInt64(0xdeadbeef))) + for i in 1:8 + buf[8 + i] = UInt8(i) end - @test rv === :sentinel end + raw = LMDB.tryget(txn, dbi, "framed", Vector{UInt8}) + @test length(raw) == 16 + @test ltoh(reinterpret(UInt64, raw[1:8])[1]) == + UInt64(0xdeadbeef) + @test raw[9:16] == UInt8[1, 2, 3, 4, 5, 6, 7, 8] + + # Return value: whatever the callback returns. + rv = LMDB.put_reserved!(txn, dbi, "rv", 4) do buf + fill!(buf, 0xab) + :sentinel + end + @test rv === :sentinel end end end +end - # delete!: Bool-returning, idempotent on MDB_NOTFOUND. - mktempdir() do dir - environment(dir) do env - start(env) do txn - open(txn) do dbi - LMDB.put!(txn, dbi, "k1", "v1") - LMDB.put!(txn, dbi, "k2", "v2") - - # Present key → true, returns and entry is gone. - @test LMDB.delete!(txn, dbi, "k1") === true - @test LMDB.tryget(txn, dbi, "k1", String) === nothing - - # Missing key → false, no exception. - @test LMDB.delete!(txn, dbi, "ghost") === false - @test LMDB.delete!(txn, dbi, "k1") === false # already gone - - # Idempotent: a second delete on the same key is a no-op. - @test LMDB.delete!(txn, dbi, "k2") === true - @test LMDB.delete!(txn, dbi, "k2") === false - end +# delete!: Bool-returning, idempotent on MDB_NOTFOUND. +mktempdir() do dir + environment(dir) do env + start(env) do txn + open(txn) do dbi + LMDB.put!(txn, dbi, "k1", "v1") + LMDB.put!(txn, dbi, "k2", "v2") + + # Present key → true, returns and entry is gone. + @test LMDB.delete!(txn, dbi, "k1") === true + @test LMDB.tryget(txn, dbi, "k1", String) === nothing + + # Missing key → false, no exception. + @test LMDB.delete!(txn, dbi, "ghost") === false + @test LMDB.delete!(txn, dbi, "k1") === false # already gone + + # Idempotent: a second delete on the same key is a no-op. + @test LMDB.delete!(txn, dbi, "k2") === true + @test LMDB.delete!(txn, dbi, "k2") === false end end end +end - # replace! / pop! - mktempdir() do dir - environment(dir) do env - start(env) do txn - open(txn) do dbi - # replace! on a missing key returns nothing and creates the entry. - @test LMDB.replace!(txn, dbi, "k", "v1") === nothing - @test LMDB.tryget(txn, dbi, "k", String) == "v1" - - # replace! on an existing key returns the old value. - @test LMDB.replace!(txn, dbi, "k", "v2") == "v1" - @test LMDB.tryget(txn, dbi, "k", String) == "v2" - - # pop! returns the value and deletes. - @test LMDB.pop!(txn, dbi, "k", String) == "v2" - @test LMDB.tryget(txn, dbi, "k", String) === nothing - # pop! on a missing key returns nothing. - @test LMDB.pop!(txn, dbi, "k", String) === nothing - end +# replace! / pop! +mktempdir() do dir + environment(dir) do env + start(env) do txn + open(txn) do dbi + # replace! on a missing key returns nothing and creates the entry. + @test LMDB.replace!(txn, dbi, "k", "v1") === nothing + @test LMDB.tryget(txn, dbi, "k", String) == "v1" + + # replace! on an existing key returns the old value. + @test LMDB.replace!(txn, dbi, "k", "v2") == "v1" + @test LMDB.tryget(txn, dbi, "k", String) == "v2" + + # pop! returns the value and deletes. + @test LMDB.pop!(txn, dbi, "k", String) == "v2" + @test LMDB.tryget(txn, dbi, "k", String) === nothing + # pop! on a missing key returns nothing. + @test LMDB.pop!(txn, dbi, "k", String) === nothing end end end end + +end # @testset "DBI" diff --git a/test/dupsort.jl b/test/dupsort.jl index 2ba2bb1..2304a41 100644 --- a/test/dupsort.jl +++ b/test/dupsort.jl @@ -1,60 +1,62 @@ -module LMDB_DupSort - using LMDB - using Test - - # DupSort: a single key can hold multiple sorted values. Verify the - # dup-aware cursor ops navigate within and across keys correctly, and - # that delete!(txn, dbi, key, val) removes one specific dup. - mktempdir() do dir - environment(dir) do env - start(env) do txn - open(txn, flags = MDB_DUPSORT) do dbi - LMDB.put!(txn, dbi, "k1", "a") - LMDB.put!(txn, dbi, "k1", "b") - LMDB.put!(txn, dbi, "k1", "c") - LMDB.put!(txn, dbi, "k2", "x") - LMDB.put!(txn, dbi, "k2", "y") - - LMDB.open(txn, dbi) do cur - # Position at first entry; walk through k1's dups. - @test LMDB.seek!(cur, String) == "k1" - @test LMDB.value(cur, String) == "a" - @test LMDB.next_dup!(cur, String) == "b" - @test LMDB.next_dup!(cur, String) == "c" - @test LMDB.next_dup!(cur, String) === nothing # no more dups - - # Jump to next key, skipping any remaining dups (none here). - @test LMDB.next_nodup!(cur, String) == "k2" - @test LMDB.value(cur, String) == "x" - @test LMDB.next_dup!(cur, String) == "y" - - # Reset to first dup of current key. - @test LMDB.seek_first_dup!(cur, String) == "x" - @test LMDB.seek_last_dup!(cur, String) == "y" - @test LMDB.prev_dup!(cur, String) == "x" - - # prev_nodup! moves back to the last dup of the previous key. - @test LMDB.prev_nodup!(cur, String) == "k1" - @test LMDB.value(cur, String) == "c" - end - - # Count dups for k1 via cursor count(). - LMDB.open(txn, dbi) do cur - @test LMDB.seek!(cur, "k1", String) == "k1" - @test count(cur) == 3 - end - - # Dup-aware delete: delete!(txn, dbi, key, val) removes only - # that one duplicate. - LMDB.delete!(txn, dbi, "k1", "b") - LMDB.open(txn, dbi) do cur - @test LMDB.seek!(cur, "k1", String) == "k1" - @test LMDB.value(cur, String) == "a" - @test LMDB.next_dup!(cur, String) == "c" # "b" is gone - @test LMDB.next_dup!(cur, String) === nothing - end +using LMDB +using Test + +@testset "DupSort" begin + +# DupSort: a single key can hold multiple sorted values. Verify the +# dup-aware cursor ops navigate within and across keys correctly, and +# that delete!(txn, dbi, key, val) removes one specific dup. +mktempdir() do dir + environment(dir) do env + start(env) do txn + open(txn, flags = MDB_DUPSORT) do dbi + LMDB.put!(txn, dbi, "k1", "a") + LMDB.put!(txn, dbi, "k1", "b") + LMDB.put!(txn, dbi, "k1", "c") + LMDB.put!(txn, dbi, "k2", "x") + LMDB.put!(txn, dbi, "k2", "y") + + LMDB.open(txn, dbi) do cur + # Position at first entry; walk through k1's dups. + @test LMDB.seek!(cur, String) == "k1" + @test LMDB.value(cur, String) == "a" + @test LMDB.next_dup!(cur, String) == "b" + @test LMDB.next_dup!(cur, String) == "c" + @test LMDB.next_dup!(cur, String) === nothing # no more dups + + # Jump to next key, skipping any remaining dups (none here). + @test LMDB.next_nodup!(cur, String) == "k2" + @test LMDB.value(cur, String) == "x" + @test LMDB.next_dup!(cur, String) == "y" + + # Reset to first dup of current key. + @test LMDB.seek_first_dup!(cur, String) == "x" + @test LMDB.seek_last_dup!(cur, String) == "y" + @test LMDB.prev_dup!(cur, String) == "x" + + # prev_nodup! moves back to the last dup of the previous key. + @test LMDB.prev_nodup!(cur, String) == "k1" + @test LMDB.value(cur, String) == "c" + end + + # Count dups for k1 via cursor count(). + LMDB.open(txn, dbi) do cur + @test LMDB.seek!(cur, "k1", String) == "k1" + @test count(cur) == 3 + end + + # Dup-aware delete: delete!(txn, dbi, key, val) removes only + # that one duplicate. + LMDB.delete!(txn, dbi, "k1", "b") + LMDB.open(txn, dbi) do cur + @test LMDB.seek!(cur, "k1", String) == "k1" + @test LMDB.value(cur, String) == "a" + @test LMDB.next_dup!(cur, String) == "c" # "b" is gone + @test LMDB.next_dup!(cur, String) === nothing end end end end end + +end # @testset "DupSort" diff --git a/test/env.jl b/test/env.jl index 49a13ae..0696586 100644 --- a/test/env.jl +++ b/test/env.jl @@ -1,125 +1,204 @@ -module LMDB_Env - using LMDB - using Test - - const dbname = "testdb" - - # Open environemnt - env = create() - @test env.handle != C_NULL - @test env[:Readers] == 126 - @test env[:KeySize] == 511 - @test env[:Flags] == 0 - - # Manipulate flags - @test !isflagset(env[:Flags], Cuint(LMDB.MDB_NOSYNC)) - set!(env, LMDB.MDB_NOSYNC) - @test isflagset(env[:Flags], Cuint(LMDB.MDB_NOSYNC)) - unset!(env, LMDB.MDB_NOSYNC) - @test !isflagset(env[:Flags], Cuint(LMDB.MDB_NOSYNC)) - - # Parameters - @test (env[:Readers] = 100) == 100 - @test (env[:MapSize] = 1000^2) == 1000^2 - @test (env[:DBs] = 10) == 10 - @test env[:Readers] == 100 - - # Map sizes >4 GiB must not silently truncate (issue #38). - big_mapsize = Csize_t(5) * 1024 * 1024 * 1024 - @test (env[:MapSize] = big_mapsize) == big_mapsize - - # :Flags setindex! used to fall through to a warn(...) that no longer - # exists, throwing UndefVarError (issue #24). - env[:Flags] = LMDB.MDB_NOSYNC - @test isflagset(env[:Flags], Cuint(LMDB.MDB_NOSYNC)) - unset!(env, LMDB.MDB_NOSYNC) - - # Unknown options used to be silently swallowed. - @test_throws ArgumentError env[:Bogus] = 1 - - # open db - isdir(dbname) || mkdir(dbname) +using LMDB +using Test + +@testset "Environment" begin + +# Open environment +env = create() +@test env.handle != C_NULL +@test env[:Readers] == 126 +@test env[:KeySize] == 511 +@test env[:Flags] == 0 + +# Manipulate flags +@test !isflagset(env[:Flags], Cuint(LMDB.MDB_NOSYNC)) +set!(env, LMDB.MDB_NOSYNC) +@test isflagset(env[:Flags], Cuint(LMDB.MDB_NOSYNC)) +unset!(env, LMDB.MDB_NOSYNC) +@test !isflagset(env[:Flags], Cuint(LMDB.MDB_NOSYNC)) + +# Parameters +@test (env[:Readers] = 100) == 100 +@test (env[:MapSize] = 1000^2) == 1000^2 +@test (env[:DBs] = 10) == 10 +@test env[:Readers] == 100 + +# MapSize must accept values that don't fit in Cuint (#38, PR #37, #40). +big = Csize_t(8) * 1024^3 # 8 GiB +@test (env[:MapSize] = big) == big + +# Setting :Flags via setindex! used to fall through to a warning (#24). +@test !isflagset(env[:Flags], Cuint(LMDB.MDB_NOSYNC)) +env[:Flags] = LMDB.MDB_NOSYNC +@test isflagset(env[:Flags], Cuint(LMDB.MDB_NOSYNC)) +unset!(env, LMDB.MDB_NOSYNC) + +# Unknown options error instead of silently warning + returning bogus values. +@test_throws ArgumentError env[:Bogus] = 1 +@test_throws ArgumentError env[:Bogus] + +# Open a DB on the env, then close it. +mktempdir() do dir + ret = open(env, dir) + @test ret[1] == 0 + + # stat(env) returns the main DB's stats; before any puts, there are + # no entries and a positive page size. + s = stat(env) + @test s isa NamedTuple + @test s.psize > 0 + @test s.entries == 0 + + # Close environment + close(env) + @test !isopen(env) + + # do block + create() do env + set!(env, LMDB.MDB_NOSYNC) + open(env, dir) + @test isopen(env) + end +end + +# High-level Environment(path; ...) constructor. +mktempdir() do dir + big = Csize_t(8) * 1024^3 + env = Environment(dir; mapsize = big, maxreaders = 42, maxdbs = 4, + flags = MDB_NOSYNC | MDB_NOTLS) try - ret = open(env, dbname) - @test ret[1] == 0 + @test isopen(env) + @test env[:Readers] == 42 + @test info(env).mapsize == big + @test isflagset(env[:Flags], Cuint(MDB_NOSYNC)) + @test isflagset(env[:Flags], Cuint(MDB_NOTLS)) + finally + close(env) + end - # stat(env) returns the main DB's stats; before any puts, there are - # no entries and a positive page size. - s = stat(env) - @test s isa NamedTuple - @test s.psize > 0 - @test s.entries == 0 + # On failure during open, the Environment ctor closes the partial env. + @test_throws LMDBError Environment(joinpath(dir, "definitely_does_not_exist")) +end - # Close environment +# Finalizers: an abandoned write txn must be aborted by GC so a later +# write txn doesn't block on LMDB's exclusive write mutex. If the +# finalizer doesn't fire, `start(env)` below would deadlock. +mktempdir() do dir + env = Environment(dir) + try + # Open a write txn and let it become unreachable without commit/abort. + let txn = start(env) + @test isopen(txn) + end + GC.gc(); GC.gc() + # If the finalizer aborted the abandoned txn, this succeeds. + txn2 = start(env) + try + dbi = open(txn2) + LMDB.put!(txn2, dbi, "k", "v") + finally + commit(txn2) + end + finally close(env) - @test !isopen(env) + end +end - # do block - create() do env - set!(env, LMDB.MDB_NOSYNC) - open(env, dbname) - @test isopen(env) +# Cursor finalizer: an abandoned cursor must be cleaned up so its +# parent txn can commit. (LMDB requires cursors on a write txn to be +# closed before commit; for read txns it's safer too.) +mktempdir() do dir + env = Environment(dir) + try + start(env) do txn + dbi = open(txn) + let cur = LMDB.open(txn, dbi) + @test isopen(cur) + end # cur out of scope + GC.gc(); GC.gc() + # If the finalizer ran, we can still use the txn. + LMDB.put!(txn, dbi, "k", "v") end finally - rm(dbname, recursive=true) + close(env) + end +end + +# Cursor finalizer is safe even after its parent txn has been +# explicitly committed: write-txn cursors are freed by the txn's +# commit per `lmdb.h`, so `mdb_cursor_close` afterwards would be UB. +# The defensive check in `close(::Cursor)` skips the LMDB call once +# the parent txn handle is gone. +mktempdir() do dir + env = Environment(dir) + try + txn = start(env) + dbi = open(txn) + cur = LMDB.open(txn, dbi) + LMDB.put!(txn, dbi, "k", "v") + commit(txn) # invalidates write-txn cursors + @test !isopen(txn) + cur = nothing # drop the binding + GC.gc(); GC.gc() # finalizer should be a no-op + finally + close(env) end +end - # High-level Environment(path; ...) constructor. - mktempdir() do dir - big = Csize_t(8) * 1024^3 - env = Environment(dir; mapsize = big, maxreaders = 42, maxdbs = 4, - flags = MDB_NOSYNC | MDB_NOTLS) - try - @test isopen(env) - @test env[:Readers] == 42 - @test info(env).mapsize == big - @test isflagset(env[:Flags], Cuint(MDB_NOSYNC)) - @test isflagset(env[:Flags], Cuint(MDB_NOTLS)) - finally - close(env) +# Parent refs: env(txn) and transaction(cur) return the actual parents. +mktempdir() do dir + env = Environment(dir) + try + start(env) do txn + @test LMDB.env(txn) === env + dbi = open(txn) + LMDB.open(txn, dbi) do cur + @test LMDB.transaction(cur) === txn + end end - - # On failure during open, the Environment ctor closes the partial env. - @test_throws LMDBError Environment(joinpath(dir, "definitely_does_not_exist")) + finally + close(env) end +end - # reader_check / reader_list / copy - mktempdir() do dir - environment(dir) do env - # Fresh env: no stale readers. - @test reader_check(env) == 0 - - # reader_list always emits a header line listing slot fields. - txt = reader_list(env) - @test txt isa String - @test !isempty(txt) - - # Round-trip a copy. - start(env) do txn - open(txn) do dbi - LMDB.put!(txn, dbi, "k", "v") - end +# reader_check / reader_list / copy +mktempdir() do dir + environment(dir) do env + # Fresh env: no stale readers. + @test reader_check(env) == 0 + + # reader_list always emits a header line listing slot fields. + txt = reader_list(env) + @test txt isa String + @test !isempty(txt) + + # Round-trip a copy. + start(env) do txn + open(txn) do dbi + LMDB.put!(txn, dbi, "k", "v") end - mktempdir() do dst - copy(env, dst) - environment(dst) do env2 - start(env2) do txn - open(txn) do dbi - @test LMDB.tryget(txn, dbi, "k", String) == "v" - end + end + mktempdir() do dst + copy(env, dst) + environment(dst) do env2 + start(env2) do txn + open(txn) do dbi + @test LMDB.tryget(txn, dbi, "k", String) == "v" end end end - mktempdir() do dst - copy(env, dst; compact=true) - environment(dst) do env2 - start(env2) do txn - open(txn) do dbi - @test LMDB.tryget(txn, dbi, "k", String) == "v" - end + end + mktempdir() do dst + copy(env, dst; compact=true) + environment(dst) do env2 + start(env2) do txn + open(txn) do dbi + @test LMDB.tryget(txn, dbi, "k", String) == "v" end end end end end end + +end # @testset "Environment" diff --git a/test/integration.jl b/test/integration.jl index bd8387d..d5ed6e3 100644 --- a/test/integration.jl +++ b/test/integration.jl @@ -1,106 +1,108 @@ -module LMDB_Integration - using LMDB - using Test +using LMDB +using Test - # cuTile-shaped framed value: 8-byte LE atime prefix, then the payload. - # Defined here at module scope to demonstrate the `Base.read(::IO, …)` - # extension contract and to regression-guard it (a downstream package - # — cuTile's DiskCache — relies on being able to plug in its own - # decoder against the abstract `IO`, without depending on - # `LMDB.MDBValueIO` in its own type signatures). - struct AtimedBlob end - const _ATIME_PREFIX = 8 +# cuTile-shaped framed value: 8-byte LE atime prefix, then the payload. +# Defined at file scope to demonstrate the `Base.read(::IO, …)` +# extension contract and to regression-guard it (a downstream package +# — cuTile's DiskCache — relies on being able to plug in its own +# decoder against the abstract `IO`, without depending on +# `LMDB.MDBValueIO` in its own type signatures). +struct AtimedBlob end +const _ATIME_PREFIX = 8 - function Base.read(io::IO, ::Type{AtimedBlob}) - bytesavailable(io) < _ATIME_PREFIX && return UInt8[] - skip(io, _ATIME_PREFIX) - return read(io, Vector{UInt8}) - end +function Base.read(io::IO, ::Type{AtimedBlob}) + bytesavailable(io) < _ATIME_PREFIX && return UInt8[] + skip(io, _ATIME_PREFIX) + return read(io, Vector{UInt8}) +end - pack_atimed(atime::UInt64, payload::Vector{UInt8}) = begin - out = Vector{UInt8}(undef, _ATIME_PREFIX + length(payload)) - GC.@preserve out unsafe_store!(Ptr{UInt64}(pointer(out)), htol(atime)) - copyto!(out, _ATIME_PREFIX + 1, payload, 1, length(payload)) - out - end +pack_atimed(atime::UInt64, payload::Vector{UInt8}) = begin + out = Vector{UInt8}(undef, _ATIME_PREFIX + length(payload)) + GC.@preserve out unsafe_store!(Ptr{UInt64}(pointer(out)), htol(atime)) + copyto!(out, _ATIME_PREFIX + 1, payload, 1, length(payload)) + out +end - # Power-user pattern: open an env via the Environment kwargs ctor, run - # a write txn through tier-2, then a read txn through a cursor walk - # using only tier-2 + raw MDB_val refs (the shape cuTile.DiskCache - # follows). Regression guard: ensures no future change breaks the - # `walk(...) do k_ref, v_ref` zero-copy idiom. - mktempdir() do dir - env = Environment(dir; - mapsize = 1 << 28, - maxreaders = 64, - flags = MDB_NOTLS | MDB_NORDAHEAD) - try - dbi, psize = start(env) do txn - d = open(txn) - (d, LMDB.stat(txn, d).psize) - end - @test psize > 0 +@testset "Integration" begin - # Populate. - start(env) do txn - for i in 1:5 - LMDB.put!(txn, dbi, "key$(i)", "value$(i)") - end +# Power-user pattern: open an env via the Environment kwargs ctor, run +# a write txn through the Julia wrappers, then a read txn through a cursor +# walk using only the Julia wrappers + raw MDB_val refs (the shape +# cuTile.DiskCache follows). Regression guard: ensures no future change +# breaks the `walk(...) do k_ref, v_ref` zero-copy idiom. +mktempdir() do dir + env = Environment(dir; + mapsize = 1 << 28, + maxreaders = 64, + flags = MDB_NOTLS | MDB_NORDAHEAD) + try + dbi, psize = start(env) do txn + d = open(txn) + (d, LMDB.stat(txn, d).psize) + end + @test psize > 0 + + # Populate. + start(env) do txn + for i in 1:5 + LMDB.put!(txn, dbi, "key$(i)", "value$(i)") end + end - # Tier-2 read txn + cursor walk over the LMDB-owned mmap, like - # cuTile's eviction scan: zero allocations beyond the per-entry - # tuple. - entries = Tuple{String, Int}[] - start(env; flags = MDB_RDONLY) do txn - LMDB.open(txn, dbi) do cur - LMDB.walk(cur) do k_ref, v_ref - kv = k_ref[]; vv = v_ref[] - k = unsafe_string(Ptr{UInt8}(kv.mv_data), kv.mv_size) - push!(entries, (k, Int(vv.mv_size))) - end + # Julia-API read txn + cursor walk over the LMDB-owned mmap, like + # cuTile's eviction scan: zero allocations beyond the per-entry + # tuple. + entries = Tuple{String, Int}[] + start(env; flags = MDB_RDONLY) do txn + LMDB.open(txn, dbi) do cur + LMDB.walk(cur) do k_ref, v_ref + kv = k_ref[]; vv = v_ref[] + k = unsafe_string(Ptr{UInt8}(kv.mv_data), kv.mv_size) + push!(entries, (k, Int(vv.mv_size))) end end - @test length(entries) == 5 - @test first.(entries) == ["key$i" for i in 1:5] - @test all(e -> e[2] == sizeof("value1"), entries) + end + @test length(entries) == 5 + @test first.(entries) == ["key$i" for i in 1:5] + @test all(e -> e[2] == sizeof("value1"), entries) - # tryget vs is_notfound — common cuTile-shaped read path. - start(env; flags = MDB_RDONLY) do txn - @test LMDB.tryget(txn, dbi, "key3", String) == "value3" - @test LMDB.tryget(txn, dbi, "ghost", String) === nothing - end + # tryget vs is_notfound — common cuTile-shaped read path. + start(env; flags = MDB_RDONLY) do txn + @test LMDB.tryget(txn, dbi, "key3", String) == "value3" + @test LMDB.tryget(txn, dbi, "ghost", String) === nothing + end - # Batch delete: present keys return true, missing return false, - # no exception on either path. cuTile's `_delete_batch!` uses - # the Bool to count actual evictions. - deleted = 0 - start(env) do txn - for k in ["key1", "ghost", "key3"] - LMDB.delete!(txn, dbi, k) && (deleted += 1) - end - end - @test deleted == 2 - start(env; flags = MDB_RDONLY) do txn - @test LMDB.tryget(txn, dbi, "key1", String) === nothing - @test LMDB.tryget(txn, dbi, "key2", String) == "value2" - @test LMDB.tryget(txn, dbi, "key3", String) === nothing + # Batch delete: present keys return true, missing return false, + # no exception on either path. cuTile's `_delete_batch!` uses + # the Bool to count actual evictions. + deleted = 0 + start(env) do txn + for k in ["key1", "ghost", "key3"] + LMDB.delete!(txn, dbi, k) && (deleted += 1) end + end + @test deleted == 2 + start(env; flags = MDB_RDONLY) do txn + @test LMDB.tryget(txn, dbi, "key1", String) === nothing + @test LMDB.tryget(txn, dbi, "key2", String) == "value2" + @test LMDB.tryget(txn, dbi, "key3", String) === nothing + end - # MDBValueIO extension: write an 8-byte-prefixed framed value - # and read it back via `tryget(..., AtimedBlob)`, getting the - # payload tail with one alloc + skip + copy, no slicing. - payload = Vector{UInt8}("cubin-bytes-here") - atime = UInt64(0xdeadbeefcafebabe) - start(env) do txn - LMDB.put!(txn, dbi, "framed", pack_atimed(atime, payload)) - end - start(env; flags = MDB_RDONLY) do txn - @test LMDB.tryget(txn, dbi, "framed", AtimedBlob) == payload - @test LMDB.tryget(txn, dbi, "ghost", AtimedBlob) === nothing - end - finally - close(env) + # MDBValueIO extension: write an 8-byte-prefixed framed value + # and read it back via `tryget(..., AtimedBlob)`, getting the + # payload tail with one alloc + skip + copy, no slicing. + payload = Vector{UInt8}("cubin-bytes-here") + atime = UInt64(0xdeadbeefcafebabe) + start(env) do txn + LMDB.put!(txn, dbi, "framed", pack_atimed(atime, payload)) end + start(env; flags = MDB_RDONLY) do txn + @test LMDB.tryget(txn, dbi, "framed", AtimedBlob) == payload + @test LMDB.tryget(txn, dbi, "ghost", AtimedBlob) === nothing + end + finally + close(env) end end + +end # @testset "Integration" diff --git a/test/liblmdb.jl b/test/liblmdb.jl index 931c40b..586a317 100644 --- a/test/liblmdb.jl +++ b/test/liblmdb.jl @@ -1,54 +1,56 @@ -module LMDB_LibLMDB - using LMDB - using Test - - # @checked: status-returning bindings auto-throw an LMDBError on a - # non-zero return. - mktempdir() do dir - env_ref = Ref{Ptr{LMDB.MDB_env}}(C_NULL) - LMDB.mdb_env_create(env_ref) - env = env_ref[] - try - # Opening a *file* (not a directory) when MDB_NOSUBDIR isn't - # set should fail with a non-zero status that @checked turns - # into an LMDBError. - f = touch(joinpath(dir, "not_a_dir")) - @test_throws LMDBError LMDB.mdb_env_open(env, - f, Cuint(0), LMDB.mode_t(0o644)) - finally - LMDB.mdb_env_close(env) - end +using LMDB +using Test + +@testset "liblmdb" begin + +# @checked: status-returning bindings auto-throw an LMDBError on a +# non-zero return. +mktempdir() do dir + env_ref = Ref{Ptr{LMDB.MDB_env}}(C_NULL) + LMDB.mdb_env_create(env_ref) + env = env_ref[] + try + # Opening a *file* (not a directory) when MDB_NOSUBDIR isn't + # set should fail with a non-zero status that @checked turns + # into an LMDBError. + f = touch(joinpath(dir, "not_a_dir")) + @test_throws LMDBError LMDB.mdb_env_open(env, + f, Cuint(0), LMDB.mode_t(0o644)) + finally + LMDB.mdb_env_close(env) end +end + +# unchecked_*: returns the raw Cint without throwing — caller decides. +mktempdir() do dir + env_ref = Ref{Ptr{LMDB.MDB_env}}(C_NULL) + LMDB.mdb_env_create(env_ref) + env = env_ref[] + try + LMDB.mdb_env_open(env, dir, Cuint(0), LMDB.mode_t(0o755)) + + txn_ref = Ref{Ptr{LMDB.MDB_txn}}(C_NULL) + LMDB.mdb_txn_begin(env, C_NULL, Cuint(0), txn_ref) + txn = txn_ref[] - # unchecked_*: returns the raw Cint without throwing — caller decides. - mktempdir() do dir - env_ref = Ref{Ptr{LMDB.MDB_env}}(C_NULL) - LMDB.mdb_env_create(env_ref) - env = env_ref[] - try - LMDB.mdb_env_open(env, dir, Cuint(0), LMDB.mode_t(0o755)) - - txn_ref = Ref{Ptr{LMDB.MDB_txn}}(C_NULL) - LMDB.mdb_txn_begin(env, C_NULL, Cuint(0), txn_ref) - txn = txn_ref[] - - dbi_ref = Ref{LMDB.MDB_dbi}(0) - LMDB.mdb_dbi_open(txn, C_NULL, Cuint(0), dbi_ref) - dbi = dbi_ref[] - - # Look up a key that doesn't exist — unchecked returns - # MDB_NOTFOUND, no exception. - key = "missing" - val_ref = Ref(LMDB.MDBValue()) - ret = LMDB.unchecked_mdb_get(txn, dbi, key, val_ref) - @test ret == LMDB.MDB_NOTFOUND - - # The checked counterpart throws. - @test_throws LMDBError LMDB.mdb_get(txn, dbi, key, val_ref) - - LMDB.mdb_txn_abort(txn) - finally - LMDB.mdb_env_close(env) - end + dbi_ref = Ref{LMDB.MDB_dbi}(0) + LMDB.mdb_dbi_open(txn, C_NULL, Cuint(0), dbi_ref) + dbi = dbi_ref[] + + # Look up a key that doesn't exist — unchecked returns + # MDB_NOTFOUND, no exception. + key = "missing" + val_ref = Ref(LMDB.MDBValue()) + ret = LMDB.unchecked_mdb_get(txn, dbi, key, val_ref) + @test ret == LMDB.MDB_NOTFOUND + + # The checked counterpart throws. + @test_throws LMDBError LMDB.mdb_get(txn, dbi, key, val_ref) + + LMDB.mdb_txn_abort(txn) + finally + LMDB.mdb_env_close(env) end end + +end # @testset "liblmdb" From 097146246847433eac0a9a76edaa5ecc0f5d82eb Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Fri, 22 May 2026 11:04:07 +0200 Subject: [PATCH 2/3] Fix Transaction finalizer crash and Linux 1.10 hang in env tests. Two bugs both surfacing on Julia 1.10 CI: - `_finalize_txn` calls `mdb_txn_abort` on a dangling handle when `close(env)` ran first (the env close already freed the txn memory via `mdb_env_close`). Mirrors the existing defensive check in `close(::Cursor)`. - The finalizer tests relied on `GC.gc()` to synchronously run finalizers, but on Julia 1.10+ finalizers may be queued to a separate task. Switched to explicit `finalize(...)` calls so the tests no longer race the GC scheduler. Also adds a test that exercises the txn-finalized-after-env-closed ordering directly (this was reproducing the Windows access violation). --- src/txn.jl | 3 +++ test/env.jl | 39 +++++++++++++++++++++++---------------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/txn.jl b/src/txn.jl index 13d5dd3..97c7217 100644 --- a/src/txn.jl +++ b/src/txn.jl @@ -56,6 +56,9 @@ Idempotent: safe to call after a previous `commit`/`abort` or on a never-opened """ function abort(txn::Transaction) txn.handle == C_NULL && return + # If env was closed first, `mdb_env_close` already freed the txn memory, + # so the handle is dangling. Same defensive check as `close(::Cursor)`. + isopen(txn.env) || (txn.handle = C_NULL; return) mdb_txn_abort(txn) txn.handle = C_NULL return diff --git a/test/env.jl b/test/env.jl index 0696586..c96a101 100644 --- a/test/env.jl +++ b/test/env.jl @@ -80,19 +80,17 @@ mktempdir() do dir @test_throws LMDBError Environment(joinpath(dir, "definitely_does_not_exist")) end -# Finalizers: an abandoned write txn must be aborted by GC so a later -# write txn doesn't block on LMDB's exclusive write mutex. If the -# finalizer doesn't fire, `start(env)` below would deadlock. +# Finalizing an abandoned write txn must abort it; otherwise the next +# write txn deadlocks on LMDB's exclusive write mutex. We call `finalize` +# directly because on Julia 1.10+ `GC.gc()` may defer finalizers to a +# separate task, so it isn't a reliable trigger from a test. mktempdir() do dir env = Environment(dir) try - # Open a write txn and let it become unreachable without commit/abort. - let txn = start(env) - @test isopen(txn) - end - GC.gc(); GC.gc() - # If the finalizer aborted the abandoned txn, this succeeds. - txn2 = start(env) + txn = start(env) + @test isopen(txn) + finalize(txn) + txn2 = start(env) # deadlocks here if the finalizer didn't abort txn try dbi = open(txn2) LMDB.put!(txn2, dbi, "k", "v") @@ -112,10 +110,9 @@ mktempdir() do dir try start(env) do txn dbi = open(txn) - let cur = LMDB.open(txn, dbi) - @test isopen(cur) - end # cur out of scope - GC.gc(); GC.gc() + cur = LMDB.open(txn, dbi) + @test isopen(cur) + finalize(cur) # If the finalizer ran, we can still use the txn. LMDB.put!(txn, dbi, "k", "v") end @@ -138,13 +135,23 @@ mktempdir() do dir LMDB.put!(txn, dbi, "k", "v") commit(txn) # invalidates write-txn cursors @test !isopen(txn) - cur = nothing # drop the binding - GC.gc(); GC.gc() # finalizer should be a no-op + finalize(cur) # finalizer should be a safe no-op finally close(env) end end +# Symmetric case for the Transaction finalizer: `mdb_env_close` frees the +# txn memory, so a later `mdb_txn_abort` would dereference a dangling +# handle. The finalizer must skip the C call when the env is already closed. +mktempdir() do dir + env = Environment(dir) + txn = start(env) + @test isopen(txn) + close(env) + finalize(txn) # finalizer should be a safe no-op +end + # Parent refs: env(txn) and transaction(cur) return the actual parents. mktempdir() do dir env = Environment(dir) From caeee08f5c3f1bf6df34ed7adba45850350275c7 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Fri, 22 May 2026 11:12:30 +0200 Subject: [PATCH 3/3] Abort open transactions when closing the Environment. LMDB's `mdb_env_close` requires all transactions to be closed first; otherwise it leaves the lockfile/heap in a corrupted state and the next env-open in the same process crashes inside `mdb_txn_renew0`. On Julia 1.10 we got away with it (different allocator behavior), but it reliably crashed on 1.11+. Track live transactions on the Environment as `WeakRef`s and abort any still-open ones in `close(env)` before delegating to `mdb_env_close`. Tests cover both the abort-on-close path and the canary case (open a new env after misuse and confirm it survives). --- src/env.jl | 14 +++++++++++++- src/txn.jl | 2 ++ test/env.jl | 19 +++++++++++++++---- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/env.jl b/src/env.jl index 211a2df..dc1a185 100644 --- a/src/env.jl +++ b/src/env.jl @@ -8,8 +8,12 @@ handle: it will be closed when the wrapper is garbage-collected, unless mutable struct Environment handle::Ptr{MDB_env} path::String + # Live transactions, tracked weakly so they remain GC-able. `close` + # walks this list to abort any still-open txn before calling + # `mdb_env_close`; otherwise LMDB corrupts state shared across envs. + txns::Vector{WeakRef} function Environment(h::Ptr{MDB_env} = C_NULL) - e = new(h, "") + e = new(h, "", WeakRef[]) finalizer(close, e) return e end @@ -105,6 +109,14 @@ finalizers safe to run after an explicit close. """ function close(env::Environment) env.handle == C_NULL && return zero(Cint) + # LMDB requires all transactions to be closed before `mdb_env_close`; + # otherwise it leaves shared lockfile/heap state corrupted and the + # next env-open in the process can crash inside `mdb_txn_renew0`. + for wr in env.txns + t = wr.value + t isa Transaction && t.handle != C_NULL && abort(t) + end + empty!(env.txns) mdb_env_close(env) env.handle = C_NULL env.path = "" diff --git a/src/txn.jl b/src/txn.jl index 97c7217..b5dd51c 100644 --- a/src/txn.jl +++ b/src/txn.jl @@ -12,6 +12,8 @@ mutable struct Transaction env::Environment function Transaction(env::Environment, h::Ptr{MDB_txn}) t = new(h, env) + # Track on the env so `close(env)` can abort us if the caller forgot. + push!(env.txns, WeakRef(t)) finalizer(_finalize_txn, t) return t end diff --git a/test/env.jl b/test/env.jl index c96a101..84fcd5f 100644 --- a/test/env.jl +++ b/test/env.jl @@ -141,15 +141,26 @@ mktempdir() do dir end end -# Symmetric case for the Transaction finalizer: `mdb_env_close` frees the -# txn memory, so a later `mdb_txn_abort` would dereference a dangling -# handle. The finalizer must skip the C call when the env is already closed. +# close(env) must abort any still-open child txns before calling +# `mdb_env_close`; otherwise LMDB corrupts shared state and a later +# env-open in the same process crashes inside `mdb_txn_renew0`. The +# subsequent Environment is the canary. mktempdir() do dir env = Environment(dir) txn = start(env) @test isopen(txn) + close(env) # would corrupt LMDB state without txn tracking + @test !isopen(txn) + finalize(txn) # finalizer should also be a safe no-op +end +mktempdir() do dir + env = Environment(dir; mapsize = Csize_t(8) * 1024^3, maxreaders = 42, maxdbs = 4) + start(env) do txn + open(txn) do dbi + LMDB.put!(txn, dbi, "k", "v") + end + end close(env) - finalize(txn) # finalizer should be a safe no-op end # Parent refs: env(txn) and transaction(cur) return the actual parents.