Proposal: add opt-in NDB_FLAG_NOTLS for multiple read txns per thread
### Problem
Consumers with complex UI lifecycles can open overlapping read transactions on the same thread. With LMDB default TLS behavior, this fails with `MDB_BAD_RSLOT` (one read txn per thread).
A common workaround is transaction inheritance (child lookups reuse parent txn), which avoids `MDB_BAD_RSLOT` but pins a stale snapshot and misses newly committed writes. For example, in damus iOS: a SwiftUI body evaluation opens a read txn, then the ingester commits a new event, then a nested lookup inherits the parent's stale snapshot and returns nil — even though the event exists (see [damus-io/damus#3607](https://github.com/damus-io/damus/issues/3607)).
### Proposal
Add an opt-in nostrdb flag that maps to `MDB_NOTLS` at env-open time.
1. Add a new config flag bit:
```c
#define NDB_FLAG_NOTLS (1 << 5) // next free bit after NDB_FLAG_NO_STATS
```
2. Thread flags into LMDB init and map to LMDB flags:
```c
static int ndb_init_lmdb(const char *filename, struct ndb_lmdb *lmdb,
size_t mapsize, uint32_t ndb_flags)
{
unsigned int mdb_flags = 0;
if (ndb_flags & NDB_FLAG_NOTLS) {
mdb_flags |= MDB_NOTLS;
}
if ((rc = mdb_env_open(lmdb->env, filename, mdb_flags, 0664))) {
...
}
}
```
3. Pass `config->flags` from `ndb_init` into `ndb_init_lmdb`.
### Downstream use (example: damus iOS)
Consumers that need overlapping read txns on the same thread can pass `NDB_FLAG_NOTLS` and remove txn-inheritance workarounds. Each `NdbTxn` then owns its own read txn and closes it explicitly.
### Tradeoffs
**Benefits**
- Avoids `MDB_BAD_RSLOT` for same-thread overlapping read txns
- Each read txn gets an independent snapshot
- Keeps default behavior unchanged for existing consumers
**Risks**
- With `MDB_NOTLS`, leaked read txns consume reader slots until txn close or env close
- Every `ndb_begin_query` must be paired with `ndb_end_query`, including teardown and error paths
- `mdb_reader_check()` does not reclaim same-process leaked txns — it only clears stale readers from dead other processes
- Opening many concurrent read txns without closing them can eventually hit `MDB_READERS_FULL`. In practice this is unlikely — realistic peak for a mobile app is ~15 concurrent readers against the LMDB default of 126 slots. Neither nostrdb nor damus iOS currently overrides this default. Failure mode is graceful (`ndb_begin_query` returns 0, not a crash).
### Why opt-in (not default)
- Preserves existing semantics for consumers that rely on one-txn-per-thread behavior
- Retains TLS-based cleanup safety unless explicitly disabled
- Lets each consumer choose based on its lifecycle and concurrency model
### Suggested tests
1. Without `NDB_FLAG_NOTLS`: second overlapping same-thread read txn fails (`MDB_BAD_RSLOT` path)
2. With `NDB_FLAG_NOTLS`: overlapping same-thread read txns both succeed and are distinct handles
3. Stress open/close loops: no unbounded reader-slot growth under normal operation
关闭于 2026-02-10 4 条评论