Remove transaction inheritance

Transaction inheritance causes issues such as:
1. higher fragmentation in the db file, causing storage inefficiencies.
2. stale transactions holding an old view of the database, causing notes
   to be unavailable in certain scenarios

This should be safe because direct access to transactions is hidden
within the NostrDB Swift modules behind safe interfaces that prevent
direct access, thus reducing or eliminating the chance of accidentally
starting two transactions within the same thread (which would cause a
deadlock).

Closes: https://github.com/damus-io/damus/issues/3681
Changelog-Fixed: Disabled transaction inhe
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Co-Authored-By: alltheseas <alltheseas@users.noreply.github.com>
This commit is contained in:
Daniel D’Aquino
2026-03-14 01:08:48 -07:00
co-authored by alltheseas
parent 0a801b01cb
commit c1fb8b592f
2 changed files with 80 additions and 132 deletions
+80 -113
View File
@@ -11,18 +11,26 @@ import Foundation
fileprivate var txn_count: Int = 0 fileprivate var txn_count: Int = 0
#endif #endif
/// Standard timeout for nostrdb transaction operations.
///
/// This timeout prevents indefinite hangs while allowing normal database operations to complete.
/// The value is chosen empirically: long enough for typical database operations on the main thread,
/// short enough to prevent user-visible freezes.
private extension DispatchTimeInterval {
static let ndbTransactionTimeout = DispatchTimeInterval.milliseconds(200)
}
// Would use struct and ~Copyable but generics aren't supported well // Would use struct and ~Copyable but generics aren't supported well
class NdbTxn<T>: RawNdbTxnAccessible { class NdbTxn<T>: RawNdbTxnAccessible {
var txn: ndb_txn var txn: ndb_txn
private var val: T! private var val: T!
var moved: Bool var moved: Bool
var inherited: Bool
var ndb: Ndb var ndb: Ndb
var generation: Int var generation: Int
var name: String var name: String
static func pure(ndb: Ndb, val: T) -> NdbTxn<T> { static func pure(ndb: Ndb, val: T) -> NdbTxn<T> {
.init(ndb: ndb, txn: ndb_txn(), val: val, generation: ndb.generation, inherited: true, name: "pure_txn") .init(ndb: ndb, txn: ndb_txn(), val: val, generation: ndb.generation, name: "pure_txn")
} }
/// Simple helper struct for the init function to avoid compiler errors encountered by using other techniques /// Simple helper struct for the init function to avoid compiler errors encountered by using other techniques
@@ -36,36 +44,21 @@ class NdbTxn<T>: RawNdbTxnAccessible {
self.name = name ?? "txn" self.name = name ?? "txn"
self.ndb = ndb self.ndb = ndb
self.generation = ndb.generation self.generation = ndb.generation
if let active_txn = Thread.current.threadDictionary["ndb_txn"] as? ndb_txn,
let txn_generation = Thread.current.threadDictionary["txn_generation"] as? Int, // Always create fresh transaction
txn_generation == ndb.generation let result: R? = try? ndb.withNdb({
{ var txn = ndb_txn()
// some parent thread is active, use that instead let ok = ndb_begin_query(ndb.ndb.ndb, &txn) != 0
print("txn: inherited txn") guard ok else { return .none }
self.txn = active_txn #if TXNDEBUG
self.inherited = true txn_count += 1
self.generation = Thread.current.threadDictionary["txn_generation"] as! Int #endif
let ref_count = Thread.current.threadDictionary["ndb_txn_ref_count"] as! Int return .some(R(txn: txn, generation: ndb.generation))
let new_ref_count = ref_count + 1 }, maxWaitTimeout: .ndbTransactionTimeout)
Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count guard let result else { return nil }
} else { self.txn = result.txn
let result: R? = try? ndb.withNdb({ self.generation = result.generation
var txn = ndb_txn()
#if TXNDEBUG
txn_count += 1
#endif
let ok = ndb_begin_query(ndb.ndb.ndb, &txn) != 0
guard ok else { return .none }
return .some(R(txn: txn, generation: ndb.generation))
}, maxWaitTimeout: .milliseconds(200))
guard let result else { return nil }
self.txn = result.txn
self.generation = result.generation
Thread.current.threadDictionary["ndb_txn"] = self.txn
Thread.current.threadDictionary["ndb_txn_ref_count"] = 1
Thread.current.threadDictionary["txn_generation"] = ndb.generation
self.inherited = false
}
#if TXNDEBUG #if TXNDEBUG
print("txn: open gen\(self.generation) '\(self.name)' \(txn_count)") print("txn: open gen\(self.generation) '\(self.name)' \(txn_count)")
#endif #endif
@@ -73,11 +66,10 @@ class NdbTxn<T>: RawNdbTxnAccessible {
self.val = with(self) self.val = with(self)
} }
private init(ndb: Ndb, txn: ndb_txn, val: T, generation: Int, inherited: Bool, name: String) { private init(ndb: Ndb, txn: ndb_txn, val: T, generation: Int, name: String) {
self.txn = txn self.txn = txn
self.val = val self.val = val
self.moved = false self.moved = false
self.inherited = inherited
self.ndb = ndb self.ndb = ndb
self.generation = generation self.generation = generation
self.name = name self.name = name
@@ -99,27 +91,15 @@ class NdbTxn<T>: RawNdbTxnAccessible {
print("txn: not closing. db closed") print("txn: not closing. db closed")
return return
} }
if let ref_count = Thread.current.threadDictionary["ndb_txn_ref_count"] as? Int {
let new_ref_count = ref_count - 1
Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count
assert(new_ref_count >= 0, "NdbTxn reference count should never be below zero")
if new_ref_count <= 0 {
_ = try? ndb.withNdb({
ndb_end_query(&self.txn)
}, maxWaitTimeout: .milliseconds(200))
Thread.current.threadDictionary.removeObject(forKey: "ndb_txn")
Thread.current.threadDictionary.removeObject(forKey: "ndb_txn_ref_count")
}
}
if inherited {
print("txn: not closing. inherited ")
return
}
if moved { if moved {
//print("txn: not closing. moved") //print("txn: not closing. moved")
return return
} }
_ = try? ndb.withNdb({
ndb_end_query(&self.txn)
}, maxWaitTimeout: .ndbTransactionTimeout)
#if TXNDEBUG #if TXNDEBUG
txn_count -= 1; txn_count -= 1;
print("txn: close gen\(generation) '\(name)' \(txn_count)") print("txn: close gen\(generation) '\(name)' \(txn_count)")
@@ -129,14 +109,14 @@ class NdbTxn<T>: RawNdbTxnAccessible {
// functor // functor
func map<Y>(_ transform: (T) -> Y) -> NdbTxn<Y> { func map<Y>(_ transform: (T) -> Y) -> NdbTxn<Y> {
self.moved = true self.moved = true
return .init(ndb: self.ndb, txn: self.txn, val: transform(val), generation: generation, inherited: inherited, name: self.name) return .init(ndb: self.ndb, txn: self.txn, val: transform(val), generation: generation, name: self.name)
} }
// comonad!? // comonad!?
// useful for moving ownership of a transaction to another value // useful for moving ownership of a transaction to another value
func extend<Y>(_ with: (NdbTxn<T>) -> Y) -> NdbTxn<Y> { func extend<Y>(_ with: (NdbTxn<T>) -> Y) -> NdbTxn<Y> {
self.moved = true self.moved = true
return .init(ndb: self.ndb, txn: self.txn, val: with(self), generation: generation, inherited: inherited, name: self.name) return .init(ndb: self.ndb, txn: self.txn, val: with(self), generation: generation, name: self.name)
} }
} }
@@ -156,13 +136,12 @@ class SafeNdbTxn<T: ~Copyable> {
var txn: ndb_txn var txn: ndb_txn
var val: T! var val: T!
var moved: Bool var moved: Bool
var inherited: Bool
var ndb: Ndb var ndb: Ndb
var generation: Int var generation: Int
var name: String var name: String
static func pure(ndb: Ndb, val: consuming T) -> SafeNdbTxn<T> { static func pure(ndb: Ndb, val: consuming T) -> SafeNdbTxn<T> {
.init(ndb: ndb, txn: ndb_txn(), val: val, generation: ndb.generation, inherited: true, name: "pure_txn") .init(ndb: ndb, txn: ndb_txn(), val: val, generation: ndb.generation, name: "pure_txn")
} }
/// Simple helper struct for the init function to avoid compiler errors encountered by using other techniques /// Simple helper struct for the init function to avoid compiler errors encountered by using other techniques
@@ -173,52 +152,42 @@ class SafeNdbTxn<T: ~Copyable> {
static func new(on ndb: Ndb, with valueGetter: (PlaceholderNdbTxn) -> T? = { _ in () }, name: String = "txn") -> SafeNdbTxn<T>? { static func new(on ndb: Ndb, with valueGetter: (PlaceholderNdbTxn) -> T? = { _ in () }, name: String = "txn") -> SafeNdbTxn<T>? {
guard !ndb.is_closed else { return nil } guard !ndb.is_closed else { return nil }
let generation: Int
let txn: ndb_txn // Always create fresh transaction
let inherited: Bool let result: R? = try? ndb.withNdb({
if let active_txn = Thread.current.threadDictionary["ndb_txn"] as? ndb_txn, var txn = ndb_txn()
let txn_generation = Thread.current.threadDictionary["txn_generation"] as? Int, let ok = ndb_begin_query(ndb.ndb.ndb, &txn) != 0
txn_generation == ndb.generation guard ok else { return .none }
{ #if TXNDEBUG
// some parent thread is active, use that instead txn_count += 1
print("txn: inherited txn") #endif
txn = active_txn return .some(R(txn: txn, generation: ndb.generation))
inherited = true }, maxWaitTimeout: .ndbTransactionTimeout)
generation = Thread.current.threadDictionary["txn_generation"] as! Int guard let result else { return nil }
let ref_count = Thread.current.threadDictionary["ndb_txn_ref_count"] as! Int let txn = result.txn
let new_ref_count = ref_count + 1 let generation = result.generation
Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count
} else {
let result: R? = try? ndb.withNdb({
var txn = ndb_txn()
#if TXNDEBUG
txn_count += 1
#endif
let ok = ndb_begin_query(ndb.ndb.ndb, &txn) != 0
guard ok else { return .none }
return .some(R(txn: txn, generation: ndb.generation))
}, maxWaitTimeout: .milliseconds(200))
guard let result else { return nil }
txn = result.txn
generation = result.generation
Thread.current.threadDictionary["ndb_txn"] = txn
Thread.current.threadDictionary["ndb_txn_ref_count"] = 1
Thread.current.threadDictionary["txn_generation"] = ndb.generation
inherited = false
}
#if TXNDEBUG #if TXNDEBUG
print("txn: open gen\(generation) '\(name)' \(txn_count)") print("txn: open gen\(generation) '\(name)' \(txn_count)")
#endif #endif
let placeholderTxn = PlaceholderNdbTxn(txn: txn) let placeholderTxn = PlaceholderNdbTxn(txn: txn)
guard let val = valueGetter(placeholderTxn) else { return nil } guard let val = valueGetter(placeholderTxn) else {
return SafeNdbTxn<T>(ndb: ndb, txn: txn, val: val, generation: generation, inherited: inherited, name: name) // Fix leak: Close transaction before returning nil
var mutableTxn = txn
_ = try? ndb.withNdb({ ndb_end_query(&mutableTxn) }, maxWaitTimeout: .ndbTransactionTimeout)
#if TXNDEBUG
txn_count -= 1
print("txn: close (valueGetter nil) gen\(generation) '\(name)' \(txn_count)")
#endif
return nil
}
return SafeNdbTxn<T>(ndb: ndb, txn: txn, val: val, generation: generation, name: name)
} }
private init(ndb: Ndb, txn: ndb_txn, val: consuming T, generation: Int, inherited: Bool, name: String) { private init(ndb: Ndb, txn: ndb_txn, val: consuming T, generation: Int, name: String) {
self.txn = txn self.txn = txn
self.val = consume val self.val = consume val
self.moved = false self.moved = false
self.inherited = inherited
self.ndb = ndb self.ndb = ndb
self.generation = generation self.generation = generation
self.name = name self.name = name
@@ -233,27 +202,15 @@ class SafeNdbTxn<T: ~Copyable> {
print("txn: not closing. db closed") print("txn: not closing. db closed")
return return
} }
if let ref_count = Thread.current.threadDictionary["ndb_txn_ref_count"] as? Int {
let new_ref_count = ref_count - 1
Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count
assert(new_ref_count >= 0, "NdbTxn reference count should never be below zero")
if new_ref_count <= 0 {
_ = try? ndb.withNdb({
ndb_end_query(&self.txn)
}, maxWaitTimeout: .milliseconds(200))
Thread.current.threadDictionary.removeObject(forKey: "ndb_txn")
Thread.current.threadDictionary.removeObject(forKey: "ndb_txn_ref_count")
}
}
if inherited {
print("txn: not closing. inherited ")
return
}
if moved { if moved {
//print("txn: not closing. moved") //print("txn: not closing. moved")
return return
} }
_ = try? ndb.withNdb({
ndb_end_query(&self.txn)
}, maxWaitTimeout: .ndbTransactionTimeout)
#if TXNDEBUG #if TXNDEBUG
txn_count -= 1; txn_count -= 1;
print("txn: close gen\(generation) '\(name)' \(txn_count)") print("txn: close gen\(generation) '\(name)' \(txn_count)")
@@ -263,14 +220,14 @@ class SafeNdbTxn<T: ~Copyable> {
// functor // functor
func map<Y>(_ transform: (borrowing T) -> Y) -> SafeNdbTxn<Y> { func map<Y>(_ transform: (borrowing T) -> Y) -> SafeNdbTxn<Y> {
self.moved = true self.moved = true
return .init(ndb: self.ndb, txn: self.txn, val: transform(val), generation: generation, inherited: inherited, name: self.name) return .init(ndb: self.ndb, txn: self.txn, val: transform(val), generation: generation, name: self.name)
} }
// comonad!? // comonad!?
// useful for moving ownership of a transaction to another value // useful for moving ownership of a transaction to another value
func extend<Y>(_ with: (SafeNdbTxn<T>) -> Y) -> SafeNdbTxn<Y> { func extend<Y>(_ with: (SafeNdbTxn<T>) -> Y) -> SafeNdbTxn<Y> {
self.moved = true self.moved = true
return .init(ndb: self.ndb, txn: self.txn, val: with(self), generation: generation, inherited: inherited, name: self.name) return .init(ndb: self.ndb, txn: self.txn, val: with(self), generation: generation, name: self.name)
} }
consuming func maybeExtend<Y>(_ with: (consuming SafeNdbTxn<T>) -> Y?) -> SafeNdbTxn<Y>? where Y: ~Copyable { consuming func maybeExtend<Y>(_ with: (consuming SafeNdbTxn<T>) -> Y?) -> SafeNdbTxn<Y>? where Y: ~Copyable {
@@ -278,10 +235,20 @@ class SafeNdbTxn<T: ~Copyable> {
let ndb = self.ndb let ndb = self.ndb
let txn = self.txn let txn = self.txn
let generation = self.generation let generation = self.generation
let inherited = self.inherited
let name = self.name let name = self.name
guard let newVal = with(consume self) else { return nil }
return .init(ndb: ndb, txn: txn, val: newVal, generation: generation, inherited: inherited, name: name) guard let newVal = with(consume self) else {
// Fix leak: Close transaction on nil path if we own it
var mutableTxn = txn
_ = try? ndb.withNdb({ ndb_end_query(&mutableTxn) }, maxWaitTimeout: .ndbTransactionTimeout)
#if TXNDEBUG
txn_count -= 1
print("txn: close (maybeExtend nil) gen\(generation) '\(name)' \(txn_count)")
#endif
return nil
}
return .init(ndb: ndb, txn: txn, val: newVal, generation: generation, name: name)
} }
} }
@@ -304,7 +271,7 @@ extension NdbTxn where T: OptionalType {
return nil return nil
} }
self.moved = true self.moved = true
return NdbTxn<T.Wrapped>(ndb: self.ndb, txn: self.txn, val: unwrappedVal, generation: generation, inherited: inherited, name: name) return NdbTxn<T.Wrapped>(ndb: self.ndb, txn: self.txn, val: unwrappedVal, generation: generation, name: name)
} }
} }
-19
View File
@@ -164,25 +164,6 @@ final class NdbTests: XCTestCase {
let testNote = NdbNote.owned_from_json(json: testJSONWithEscapedSlashes)! let testNote = NdbNote.owned_from_json(json: testJSONWithEscapedSlashes)!
XCTAssertEqual(testNote.content, "https://cdn.nostr.build/i/5c1d3296f66c2630131bf123106486aeaf051ed8466031c0e0532d70b33cddb2.jpg") XCTAssertEqual(testNote.content, "https://cdn.nostr.build/i/5c1d3296f66c2630131bf123106486aeaf051ed8466031c0e0532d70b33cddb2.jpg")
} }
func test_inherited_transactions() throws {
let ndb = Ndb(path: db_dir)!
do {
guard let txn1 = NdbTxn(ndb: ndb) else { return XCTAssert(false) }
let ntxn = (Thread.current.threadDictionary.value(forKey: "ndb_txn") as? ndb_txn)!
XCTAssertEqual(txn1.txn.lmdb, ntxn.lmdb)
XCTAssertEqual(txn1.txn.mdb_txn, ntxn.mdb_txn)
guard let txn2 = NdbTxn(ndb: ndb) else { return XCTAssert(false) }
XCTAssertEqual(txn1.inherited, false)
XCTAssertEqual(txn2.inherited, true)
}
let ndb_txn = Thread.current.threadDictionary.value(forKey: "ndb_txn")
XCTAssertNil(ndb_txn)
}
func test_decode_perf() throws { func test_decode_perf() throws {
// This is an example of a performance test case. // This is an example of a performance test case.