LockManager enforces ELIPS' single-writer / many-reader contract for on-disk databases through a POSIX advisory file lock. It acquires an exclusive, non-blocking lock at open time and holds it for the lifetime of the ElipsInstance.
Overview
A persistent database directory contains a LOCK file. The first read-write opener takes an exclusive flock(LOCK_EX | LOCK_NB); subsequent writers fail fast with LockConflict. Read-only opens take a shared lock and may coexist with other readers. There is no background coordination — no daemon, no thread pool, just a file descriptor.
Class shape
// include/elips/kernel/LockManager.hpp
namespace elips {
class LockConflict : public ElipsError {
public:
using ElipsError::ElipsError;
};
class LockManager {
public:
explicit LockManager(const std::string& lock_path); // acquires
~LockManager(); // releases
LockManager(const LockManager&) = delete;
LockManager& operator=(const LockManager&) = delete;
LockManager(LockManager&&) noexcept; // movable
LockManager& operator=(LockManager&&) = delete;
private:
int fd_{-1};
};
} // namespace elipsLocking semantics
LockManager::LockManager(const std::string& lock_path) {
fd_ = ::open(lock_path.c_str(), O_RDWR | O_CREAT, 0644);
if (fd_ < 0) {
throw StorageError{"cannot open lock file: " + lock_path};
}
if (::flock(fd_, LOCK_EX | LOCK_NB) != 0) {
::close(fd_);
fd_ = -1;
throw LockConflict{"database is already open by another writer: " + lock_path};
}
}
LockManager::~LockManager() {
if (fd_ >= 0) {
::flock(fd_, LOCK_UN);
::close(fd_);
}
}- Path —
<db_path>/LOCK. The file is a 0-byte target forflock(2); no data is written. - LOCK_EX — exclusive lock. No other process can hold any lock on the file simultaneously.
- LOCK_NB — non-blocking. Conflicting calls return immediately with
EWOULDBLOCK, not a hang. - Release on close — POSIX releases every
flockheld on a file when any descriptor for it is closed. - Release on process exit — the kernel cleans up file descriptors, so a crashed writer is automatically unlocked.
Use in open()
std::unique_ptr<ElipsInstance> open(const std::string& path, const Config& config) {
// ... path validation ...
LockManager lock{(fs::path(path) / lock_file).string()}; // single-writer
// ... identity, snapshot load, WAL replay ...
auto instance = std::make_unique<ElipsInstance>(path, effective,
/*persistent=*/true,
std::move(lock));
return instance;
}The lock is created on the stack inside open(), moved into ElipsInstance, and released when the instance is destroyed or close() is called. If any step between acquisition and instance construction throws, stack unwinding releases the lock — there is no leakage path.
RAII guarantees
| Scenario | Lock state |
|---|---|
| Normal open → close | Acquired on open, released on close |
| ElipsInstance destructor | Released via ~LockManager() → flock(LOCK_UN) |
| Process crash / SIGKILL | Released by the kernel |
| Exception during open() before instance constructed | Released via stack unwinding |
| In-memory database (":memory:") | No lock created (no filesystem path) |
Cross-platform notes
- POSIX (macOS, Linux) — current implementation, BSD-style
flock(2). Lock follows the file descriptor. - Windows — planned via
LockFileExwithLOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY. Path handling differs.
Errors
try:
db = elips.open("path/to/db")
except elips.LockConflict as e:
print(f"Database is locked by another process: {e}")
except elips.StorageError as e:
print(f"Storage error: {e}")Related: Architecture · Transaction engine.