Share one handle per directory
Open the database during application startup and pass the handle to repositories or workers. Do not open/close it per request. The OS lock prevents another handle/process from owning the same directory.
One handle is safe for concurrent goroutines. Commits, flushes, and close acquire the exclusive mutex; lookups, scans, and stats use its shared side. Readers wait for synchronous checkpointing and vice versa.
Consistency boundaries
| Pattern | Guaranteed behavior |
|---|---|
One Get / GetRecord | A committed record or an error |
One Scan | A consistent committed collection range |
One WriteBatch | Atomic validation/publication of that batch |
Two Get calls | May observe different commits |
| Several scan pages | Independent snapshots |
Get then Put | No atomic read-modify-write guarantee |
Read Version then write | No expected-version enforcement |
The engine does not expose persistent snapshots or label arbitrary application transactions serializable.
Read-modify-write coordination
Use an application mutex around the complete logical operation, and make every code path that mutates the affected invariant use the same coordination:
mu.Lock()
defer mu.Unlock()
value, err := db.Get(ctx, "counters", []byte("jobs"))
// Decode, validate, and calculate under the same application lock.
// Treat ErrNotFound according to the application's initial-value policy.
_ = value
_ = err
This sketch is not an engine transaction callback. A mutex only coordinates callers that honor it. Other code directly using the shared DB can bypass your application invariant. Keep ownership behind a repository boundary when needed.
Use one WriteBatch for the resulting primary/index mutations so their publication is atomic. The pre-read and the commit are coordinated by the application lock, not by Version.
Contexts and deadlines
Pass a non-nil context to context-aware methods. Use a request context for work that should stop before it begins and an application context for background jobs.
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
value, err := db.Get(ctx, "users", []byte("1024"))
Import context and time. Context cancellation does not interrupt mutex waits or OS I/O. A request can exceed its deadline while waiting for a lock, then return cancellation when it reaches a check.
Once WAL append or checkpoint publication starts, cancellation cannot undo persistent work. Do not interpret a deadline as proof that no mutation exists. An I/O failure after append can be indeterminate even without cancellation.
Open, Stats, and Close do not accept a context. Plan their lifecycle accordingly.
Avoid carrying work out of a read call
Returned slices belong to the caller and can be changed without changing the DB. The input slices are copied by a write, but callers must not concurrently mutate arguments while the API is still reading them.
A scan materializes bounded results before returning; it is not a lazy cursor retaining locks. Decode expensive application payloads after the API call rather than assuming it exposes a transaction/iterator callback.
Shutdown order
- Stop admitting application work.
- Cancel background producers where appropriate.
- Wait for all workers using the DB to finish.
- Call
Closeand preserve its error.
An explicit Flush before close is optional, not a substitute for stopping writers. Durable commits already rely on the WAL. Closing does sync pending WAL data in either mode.
Use the worker lifecycle recipe for a complete bounded-concurrency example.