lintel 0.2.0
Loading...
Searching...
No Matches
Architecture

Layers

lintel is split into two halves that are compiled into one library:

  • **core** - the engine. Process startup, dependency injection, lifecycle, configuration, persistence abstraction, logging and plugins. Nothing in core needs an optional transport or UI.
  • **features** - opt-in modules that plug into the engine. Each feature registers its own types into the container and is enabled explicitly by the application:
Feature Header What it adds
PropertyFeature lintel/app/PropertyFeature.h configuration properties, repositories, change notification
BaseFeature lintel/app/BaseFeature.h user/groups/history services, shared memory, process control
CommandLineFeature lintel/app/CommandLineFeature.h interactive CLI, the reference client for the HTTP API
HttpFeature lintel/app/HttpFeature.h cpp-httplib server and client, the HTTP API

The split is both a source-level and a link-time boundary. Each layer is an exported CMake target with a strictly acyclic dependency graph (core ← base ← property ← http ← cli ← app), so an application can link only the layers it needs (e.g. lintel::property). The composition classes (StartupBuilder, ConfigurationComponentBuilder and the *Feature types) live in lintel::app, the top layer. The historical lintel::lintel aggregate target still links everything and remains the default entry point.

Bootstrap and lifecycle

Every application starts through lintel::StartupBuilder. StartupBuilder::with(argc, argv) is the factory, addFeature<FEATURE>() accumulates the feature set and start() runs the sequence. start() executes these phases in a fixed order, and the order is the contract:

  1. Type registration. Configuration, ProcessName and EnvironmentConfiguration are registered as container instances, then every feature's registerTypes(builder, configuration, processName) runs. Configuration is available at registration time on purpose: a feature can then register a type only in the process the configuration names as its owner (BaseFeature does exactly this for HistoryService).
  2. Logger. DECLARE_LOGGER(m_name, m_configuration) after registration, so log statements during later phases are routed.
  3. Signal mask. SIGINT, SIGCHLD and SIGTERM are blocked via pthread_sigmask and consumed later by a dedicated sigwait thread instead of std::signal, which avoids the async-signal-safety problems of handlers that touch C++ state.
  4. Container build. builder.build() instantiates the object graph.
  5. Bootstrap plugins. BootstrapService::onStart() runs them in BootstrapSequence order: SingleInstance, Database, MessageQueue, VirtualGroups, SharedMemory, AdminUser. The order is data dependencies, not priority preference - the admin user cannot exist before groups, and shared memory cannot be mirrored before the database is seeded.
  6. Feature initialization. Each feature's initialize(container) runs and binds services to the now-resolved graph.
  7. Arguments. ProcessArgumentService::parseArguments() consumes the command line, which is why it happens after initialization and not in phase 1.
  8. Awake. PersistableService::awake() tells every registered PersistableBean to reload its state from persistent storage.
  9. Service initialization. InitializeService::onInitialize() calls onInitialize() on every resolved lintel::AbstractServiceInterface, in container registration order.
  10. Idle. The builder waits on a condition variable until the signal thread sets the stop flag, then joins it. The destructor joins as well, so a REQUIRE failure that unwinds through a step cannot leave a joinable std::thread behind and abort the process.

Graceful shutdown

The SIGINT/SIGTERM that arrives in the sigwait thread (or a Windows console control event) calls StartupBuilder::onShutdown(). It sets the stop flag under the same mutex that start() waits on, so the wakeup cannot be lost, then sorts the resolved services by shutdownPriorityOf() ascending and calls onShutdown() on each in that order: ShutdownPriority::DEFAULT (0) services stop first, the ShutdownPriority::PROCESS_SERVICE (1) ProcessService stops last. Keeping the process manager alive to the end lets it keep reaping children (SIGCHLD) while the rest of the process winds down, and only then detach from/terminate the managed processes. When every onShutdown() has run, the condition variable is notified and start() returns, joining the signal thread. The order is pinned by tests/core/StartupBuilderTest.cpp.

The HTTP server is deliberately not part of that sequence: it is created eagerly by ServerProvider while the container is built (phase 4) and held by the container, not by an AbstractServiceInterface. It therefore keeps accepting and serving for the whole lifetime of the process and is only stopped when the container - and with it ServerProvider - is destroyed after start() returned. Server::Impl's destructor calls stop() and joins the listen thread, which lets an in-flight request finish instead of cutting the connection. A deployment that wants to stop taking new work before the services wind down has to do that at the infrastructure level (readiness below).

Dependency injection

The container is the library's own header-only lintel::di (src/include/lintel/core/di/Container.h). It replaces the former vendored Hypodermic, which was unmaintained since 2017. It is used through two interface methods rather than exposed in the API:

  • FeatureInterface::registerTypes() / FeatureInterface::initialize()
  • BootstrapPlugin::onStart() / BootstrapPlugin::getPriority()

Public headers only mention lintel::di::Container / ContainerBuilder through forward declarations where a signature needs it, so consumers are not forced to have the container's header on their include path.

Constructor parameters are injected when they are std::shared_ptr<T> (resolved as the last registration of T) or std::vector<std::shared_ptr<T>> (all registrations of T, in registration order). Unregistered concrete types are auto-registered transiently, and a thread-local activation stack turns a resumed registration (a dependency cycle) into a ResolutionException instead of unbounded recursion.

Properties

The property system is the central configuration surface: services read their settings through it instead of parsing XML themselves.

  • PropertyRepository is the storage abstraction, implemented by FilePropertyRepository, DatabasePropertyRepository and SharedMemoryPropertyRepository.
  • Repositories form a shadow chain: a repository can declare itself a shadow of another, which is how a shared-memory mirror stays in sync with the database without either side knowing about the other's storage details. This is why SharedMemoryPropertyRepository can serve processes that must not touch a database at all.
  • PropertyFactory::Create() materializes a PropertyBase from its string representation, using the recorded type name.
  • Changes are published on the event bus, so a subscriber learns about a change without polling the repository.

The bootstrap XML is expanded before parsing: every ${NAME} occurrence whose NAME matches [A-Za-z_][A-Za-z0-9_]* is replaced by the environment variable of that name via EnvironmentConfiguration::expand(). An unset variable is left as the literal text ${NAME} (it is not an error), and $${NAME} escapes to the literal ${NAME}. A malformed XML document aborts startup with a ConfigurationException that carries the tinyxml2 error and line number.

Persistence

db::Connection is one class over both backends; the concrete choice is db::ConnectionType plus a connection entry from the bootstrap XML. Around it sit db::Parameter / db::ParameterBuilder / db::PreparedStatement (bind parameters), db::Result, db::Cursor (server-side cursors for PostgreSQL), db::Transaction and db::Notify.

Values are bound as text through the generic bind, which means a caller is responsible for not passing plaintext secrets. Both password paths in the library (Cryption::hashOf on login and on change) hash before the value reaches this layer.

One limit is deliberate and documented instead of worked around: neither backend lets the library scrub a decrypted secret from its own memory. The drivers copy values into buffers the application cannot reach afterwards, so a "secure erase" of the plaintext would only clear a copy. What is kept short instead is the exposure - passwords are hashed before they reach this layer, sessions live in memory rather than in a session table, and the SQLite file is created under the user's home directory. A deployment that needs secrets scrubbed from memory needs a backend that never returns them to the client.

Connection pooling

db::ConnectionPool opens a connection once per slot and hands it out through a movable, non-copyable db::ConnectionPool::Lease (RAII: the connection returns to the pool when the lease leaves scope, also while an exception unwinds). Lease::connection() exposes the db::Connection &. One implementation covers three operating modes, selected by the poolSize attribute of a DatabaseConnection:

poolSize behaviour
0 pooling disabled - the historical connection per call, and no pool is registered
1 one persistent, serialized connection (the SQLite default)
N>1 bounded pool for parallel callers (the PostgreSQL default is 4)

The pool is thread safe (mutex plus condition variable). An acquire() waits at most poolAcquireTimeoutMs (default 5000); on expiry it throws db::PoolTimeoutException (a db::SQLException), which names the pool and its maximum and lists every outstanding lease with its std::source_location, thread id and age. A thread that already holds a lease of the same pool and acquires again fails immediately instead of waiting: the re-entrancy detector turns the typical poolSize=1 nested-call deadlock into a fast, named error. On release the connection is probed by db::Connection::isHealthy (PQstatus for PostgreSQL, a SELECT 1 probe for SQLite) and a broken connection is dropped, so the next acquire builds a fresh one.

Pools are reachable by connection name through db::ConnectionPoolRegistry, a process-wide singleton (the ReadinessRegistry pattern - the DI container resolves by type, not by name, so several pools could not be addressed). DatabaseBootstrapPlugin::onStart() registers a pool for every configured entry with poolSize > 0. The registry never creates a pool implicitly: a process without database entries (the worker's bootstrap_worker) has an empty registry and poolOf() throws db::PoolNotFoundException. The database:<name> readiness check acquires a lease and runs SELECT 1 on it, so it also exercises the pool health path; a connection without a pool keeps the direct probe.

Session state does not survive a release. A reused connection keeps SET, temporary tables and prepared statements, so code that runs on a lease must not rely on connection-local state surviving a pool release. The PostgreSQL postgresql::PreparedStatement deallocates its named statement in its destructor for exactly this reason; prefer unnamed, per-call statements for anything that must not leak. A SQLite poolSize > 1 is rejected at parse time (ConfigurationException) unless allowMultipleSqliteConnections="true" overrides it, because the file lock and WAL make parallel writers contend. A pool is not a replacement for an external pooler (PgBouncer/pgcat); it is a documented, optional complement.

ScopedConnection & lease lifetime: All direct connection constructions are replaced by db::ScopedConnection. If ConnectionPoolRegistry has a pool for the entry's name, it acquires a db::ConnectionPool::Lease; otherwise it opens a direct db::Connection (fallback when poolSize == 0 or no pool configured). The lease lives as long as the ScopedConnection.

Transactions: db::Transaction must run within the ScopedConnection scope that supplies the connection. The connection must not return to the pool before commit or rollback; keep ScopedConnection alive for the entire transaction.

Cursors: db::Cursor references db::Connection &, so the ScopedConnection that provides it must outlive the cursor. Repository cursors are method-local, which matches creating ScopedConnection in the same scope.

Bootstrap migrations: Schema migrations (initDatabase/executeInitFile) keep a single connection over the entire *_version_N.sql run; when pooled, the holding ScopedConnection must span that loop.

Shared memory and the event bus

Shared memory is boost::interprocess based and deliberately quarantined: BoostSegmentAllocator is the single adapter where ShmSegmentAccessor's allocator interface meets boost, so the rest of the library - and all examples except the ones that create segments - stay boost-free. On top of it, EventBus provides broadcast to subscribers plus point-to-point events between processes.

Cross-process access to a remapped segment is serialized by SegmentSemaphoreGuard, which acquires and releases the segment's named remap semaphore. The acquisition is bounded (SegmentSemaphoreGuard::kDefaultWaitTimeout seconds): a process that crashes while holding the semaphore leaves its count at zero, and an unbounded wait would stall every other process. On timeout the guard throws instead, and the orphaned handle can be inspected or removed out of band (examples/cleanup.cpp shows the manual path).

Sizing semantics of lintel::SharedMemoryService:

  • A segment is created with its configured initial size. With an AutoExtendConfig it also has a grow increment and a hard maxSize.
  • growOf() grows by the requested amount. A grow of zero is a no-op. With maxSize != 0 the request is capped to the remaining headroom, and growing a segment that is already at maxSize throws. With maxSize == 0 the segment is unbounded (the historical behavior).
  • shrinkOf() shrinks the file to the size its current contents need.
  • Both operations reopen the mapping (the mmap length is fixed at mapping time) and bump the generation counter, so repositories notice that references into the old mapping are invalid and re-fetch them.
  • Unknown segment names raise lintel::ShmSegmentNotFound.

Transports

The HTTP API (lintel::HttpFeature) and the CLI (lintel::CommandLineFeature) are both optional, and the CLI is deliberately the reference client for the HTTP API. The wire contract is swagger.yaml in the repository root; swagger.md is a generated mirror of it. Server behavior worth knowing:

  • /health is unauthenticated and always answers 200 with {"status":"ok"}: it is a liveness probe that must not consult any dependency, otherwise a broken database would make the supervisor restart a healthy process.
  • /ready is the readiness probe. It runs every check registered in lintel::ReadinessRegistry - a process-wide registry that components owning a dependency fill during startup (DatabaseBootstrapPlugin registers one database:<name> check per configured connection, SharedMemoryService a shared_memory check). With no check registered it answers the historical 200 {"status":"ready"}; otherwise it answers 200 with {"status":"ready","checks":{"<name>":"ok",...}} when every check passed, or 503 with {"status":"not_ready","checks":{"<name>":"failed: <reason>",...}}. A check that throws counts as failed; the body is written with rapidjson, so a reason taken from an exception message is escaped and cannot break the document. A check reconnects/pings its dependency per request, which is what a load balancer or Kubernetes probe wants: it takes the process out of rotation while a dependency is down.
  • TLS is configured per <HttpHost><Server> in the bootstrap XML. With require_tls="true" the server refuses to start without a certificate and key, which keeps credentials from being served in clear text. A certificate that OpenSSL cannot load is reported as such at startup instead of surfacing later as a failed bind.
  • A renewed certificate is applied without a restart by POST /admin/reload-tls (admin group). The running server registers a handler in lintel::TlsReloadRegistry and the endpoint runs it, answering 200 with {"status":"reloaded","results":{"tls":"ok"}}, 500 with "failed: <reason>" when a file cannot be read or the pair is rejected, and 409 {"status":"nothing_to_reload"} when the process serves clear text. The certificate pair is swapped under the context mutex the handshake path also takes, so a handshake starting at the same time sees either the old or the new certificate. The server keeps the last pair that worked and puts it back when a renewal is rejected - the loader installs the certificate before it validates the key, so a mismatched pair would otherwise leave the running server unable to complete a handshake.
  • trusted_proxies lists the proxies whose X-Forwarded-For header is believed; without it the header is ignored and the peer address is used. That list decides which address a token is bound to and which address the login rate limit and lockout are keyed by, so it has to name exactly the proxies in front of the process: a range that is too wide lets a client pick its own X-Forwarded-For and rotate it per request, which turns the per-address brute-force protection into no protection at all.
  • Request latency is a transport property, not an afterthought. The server calls set_tcp_nodelay(true): httplib leaves Nagle enabled (CPPHTTPLIB_TCP_NODELAY defaults to false) and a TLS response then waits for the peer's delayed acknowledgement, which cost ~40 ms per request and dominated every response the API sent. benchmarks/HttpBenchmark.cpp and benchmarks/baseline.json (checked by scripts/check-benchmarks.sh) pin that: reverting the option shows up as a >20x regression in BM_Http_Health_TlsKeepAlive. Nagle never delays a full segment, so the option costs nothing for bulk responses. Keep-alive is httplib's default and the client used by lintel::Client sets the option for the same reason. TLS session resumption is already effective (OpenSSL 1.3 session tickets, verified with openssl s_client -sess_out/-sess_in), and the request body is capped at 8 MiB by set_payload_max_length, which rejects larger uploads instead of buffering them.

Authentication and sessions

lintel::AuthService keeps the sessions in memory: a successful login mints a token that carries the canonical client address, and an access is rejected unless it comes from that same address. The token slides forward on every access and expires after m_timeoutLogin (5 minutes) without one; the sweep that removes them runs every m_scheduleRate seconds.

Login is protected against brute force, keyed by the canonical client address and measured in sliding windows:

  • m_maxLoginAttempts (20) attempts per m_rateLimitWindow (60 s); the next one is rejected.
  • m_maxLoginFailures (5) failures inside m_loginLockout (60 s) lock the address out until the window passes; a successful login clears the failures recorded for that address.
  • A rejected login answers exactly like a wrong password, so a caller cannot tell a locked-out address, an unknown user and a wrong password apart from the response; the reason only goes to the log.

Three consequences of the per-address keying are worth planning for:

  • Clients that share one address (NAT, or a proxy missing from trusted_proxies) share one budget, so too many failed logins behind a NAT lock out everybody behind it.
  • The address is canonicalized before it is stored, so one client in two spellings (2001:DB8::1 and 2001:db8::1, or an ipv4 client that an ipv6 listener reports in its ipv4-mapped form) is one budget and one session, not two. This also means a session cannot be logged out from a differently spelled address.
  • A client that opens many source addresses grows the per-address tables until the sweep prunes them at the end of the window; the tables are pruned per window, not per entry count.

Passwords are stored as salted hashes (lintel::Cryption). A legacy unsalted SHA-512 hash is upgraded transparently on the next successful login. Verification is skipped when the user does not exist, which is faster than verifying a wrong password - the response is identical either way, only the log entry and the timing differ.

Extension points

The intended extension points, in the order a new subsystem usually needs them:

  1. A service derives from AbstractService (which supplies the AbstractServiceInterface lifecycle and property access) and registers itself in a feature's registerTypes().
  2. A bootstrap plugin derives from lintel::BootstrapPlugin and returns a BootstrapSequence value, so it lands at the right point in phase 5.
  3. A new storage backend implements PropertyRepository, or a db::ConnectionType case for the persistence layer.
  4. A new transport implements FeatureInterface and registers its own components; nothing in core has to change.

Build and integration

Consumers use the installed CMake package:

find_package(lintel REQUIRED)
target_link_libraries(my_app PRIVATE lintel::lintel)

The package is relocatable - INSTALL_INTERFACE carries no build-machine absolute paths - so an installed tree can be moved. Dependencies come from Conan 2; CONAN_SYSREQUIRES_MODE is what lets conan install satisfy system requirements during CI.

Platform support in practice: the code is written against POSIX and the POSIX details (sigwait, localtime_r, named shared memory, FIFOs) are isolated behind lintel/core/platform/Platform.h and a handful of #if defined(_WIN32) branches. The blocking CI matrix covers GCC (Release, gate) and Clang with libstdc++ 14, plus one Debug/non-unity run, all on Ubuntu 24.04. The macos and windows jobs are advisory: they build and run the tests, but are not required checks and have no PostgreSQL coverage (see ROADMAP.md ยง8).