|
Lightweight 0.20260625.0
|
Lightweight exposes a C++23 coroutine API so applications can drive the database without blocking their main thread — both single-threaded (one app/event-loop thread) and multi-threaded.
The async entry points are added directly to the classes you already use (SqlConnection, DataMapper, Pool), suffixed with Async; only AsyncSqlTransaction is a separate type. Queries go through the same fluent builder as the synchronous API — QueryAsync<Record>() returns a builder whose finishers yield a Task (see Querying asynchronously). The supporting runtime (Task, SyncWait, executors, backends) lives under <Lightweight/Async/…>.
ODBC's asynchronous execution is not portable: the PostgreSQL ODBC driver reports no async support at all, the SQLite ODBC driver is synchronous, and SQL Server's event-based async is effectively Windows-only. So Lightweight's portable model offloads each blocking ODBC call to a worker thread and resumes your coroutine on a scheduler you choose. With synchronous drivers there is no way to avoid blocking some thread — but it never has to be your app thread.
A native event-based fast-path for SQL Server on Windows is planned behind the same API; until then every database uses the portable thread-offload backend.
| Type | Role |
|---|---|
Async::Task<T> | A lazy coroutine result. co_await it, or drive it with SyncWait. |
Async::ThreadPoolExecutor | The DB worker pool — blocking ODBC calls run here. |
Async::ManualExecutor | An app run-loop you pump yourself; the resume target for the single-threaded model. |
Async::InlineExecutor | Runs work inline on the calling thread (tests / degenerate configs). |
Async::SyncWait / SyncWaitPumping | Bridge a coroutine back to non-coroutine code. |
You inject the executors (dependency injection); Lightweight never owns them. They must outlive every coroutine that can resume on them.
Call EnableAsync(dbWorkers, resume) once. dbWorkers is where blocking calls run; resume is where your coroutine continues afterwards.
Asynchronous queries use the same fluent query builder as synchronous ones — you just start the chain with QueryAsync<Record>() instead of Query<Record>(). Every finisher (All(), First(), First(n), Range(), Count(), Exist(), Delete(), plus the field-projection variants such as All<&User::name>()) then returns a Task of its usual result instead of the result itself:
Query<Record>() (synchronous) and QueryAsync<Record>() (asynchronous) are otherwise identical: the only difference is the return type of the finisher. There is intentionally no AllAsync() next to All().
Every async finisher returns a lazy
Taskthat does nothing until it isco_await-ed — including side-effecting ones likeDelete(). Discarding the returnedTask(e.g.dm.QueryAsync<User>()… .Delete();withoutco_await) performs no work; the[[nodiscard]]attribute warns about it.
Operations that act on a record directly — and have no query-builder form — keep a dedicated …Async counterpart returning a Task:
Available: CreateAsync, QuerySingleAsync, UpdateAsync, DeleteAsync, LoadRelationsAsync (plus the QueryAsync<Record>() builder above). QuerySingleAsync<Record>(keys…) is the async shorthand for a primary-key lookup; for any other query use the builder.
Lifetime (important): the record-level methods capture the record by reference, and the builder finishers capture the builder by reference. The captured operand is dereferenced on a worker thread when the
Taskis awaited — so it must stay alive, and must not be moved or mutated, for the whole duration of theco_await(until the coroutine resumes), not merely until the call returns. Awaiting a builder temporary kept in a local (auto t = dm.QueryAsync<User>()…All(); co_await std::move(t);) destroys the builder too early and is a use-after-free. The idiomatic, safe form keeps the whole expression inside theco_await(e.g.co_await dm.QueryAsync<User>()…All();).
The same coroutine code runs in both models — only how you drive it differs.
Single-threaded (resume on the app thread). Drive the work by pumping your ManualExecutor. Blocking ODBC runs on dbWorkers; your coroutine body always resumes on the pumping thread:
In a real event loop you would instead call appLoop.Run() (or Drain() / RunOne()) from your loop tick, and start coroutines without blocking.
Multi-threaded. Use a pool and let coroutines resume on worker threads (resume scheduler = the pool). Acquire a connection without blocking via Pool::AcquireAsync:
If you prefer to pass the executors per call (or to override the configured ones for a single acquire), the explicit overload pool.AcquireAsync(offload, resume) remains available.
AcquireAsync never blocks the calling thread; its back-pressure behavior depends on the pool's growth strategy:
BoundedWait** — when the pool is at maxSize, AcquireAsync suspends the coroutine and resumes it once another caller returns a connection. No thread is parked.BoundedOverflow** (the default DataMapperPool) and **UnboundedGrow** — never suspend: if no idle connection is available a fresh one is created (matching the synchronous Acquire). Under load this can open an unbounded number of connections, so choose BoundedWait when you need back-pressure.AsyncSqlTransaction is the one distinct async type (a transaction is a scoped object):
Always co_await CommitAsync() / RollbackAsync() explicitly. CommitAsync / RollbackAsync are a point of no return and are not cancellable (they take no token) — they always run to completion, so a rollback can never silently degrade into a commit. If the transaction is still open when destroyed, the destructor finalizes it best-effort (per the configured mode — COMMIT by default, matching the synchronous SqlTransaction), routed through the connection's strand and blocking the destroying thread, so it never races an in-flight async op on the same connection. The connection must stay alive and async-enabled for the transaction's whole lifetime — do not destroy it or return the owning pooled DataMapper (which disables async) between BeginAsync and the finalizing call. A second BeginAsync without an intervening commit/rollback is a programmer error (std::logic_error).
Destruction blocks on the strand. Because the open-at-destruction finalization is routed through the connection's strand and blocks the destroying thread until it runs, do not let an open
AsyncSqlTransactionbe destroyed on a thread the strand needs in order to make progress — e.g. from inside another offloaded step on the same connection, or (in the multi-threaded model where the resume scheduler is the worker pool backing the strand) while resuming on one of those workers. With a single-worker pool that self-waits and deadlocks. Prefer explicitCommitAsync/RollbackAsyncso the destructor never has to finalize.
Async-runtime calls (e.g. BeginAsync, the offloaded query/record methods) accept an optional std::stop_token. A default-constructed token is non-cancellable, so omit it when you do not need cancellation. To cancel, hold a std::stop_source and pass its token:
Cancellation is honored only before a step is dispatched: if the token is already requested when the step is about to run, it completes immediately with Async::OperationCancelledError and never occupies a DB worker. Once a step has begun running, the in-flight blocking ODBC call is not interrupted (there is no SQLCancel integration yet), so a late request only affects the next not-yet-dispatched step. Transaction finalization (CommitAsync/RollbackAsync) is deliberately exempt and never cancels.
Exceptions thrown by the underlying synchronous call (e.g. SqlException) are captured on the worker and re-thrown at the co_await site on the resume thread — exactly as the synchronous API would throw.
Lightweight's async layer coexists with another coroutine runtime — a GUI framework, an HTTP server, your own event loop — in the same executable without any special arrangement. Async::Task and any other library's task type are distinct, namespaced types, and C++ coroutine state is per-promise-type, so there is nothing global to clash and no linker conflict. The thing that actually matters when two runtimes share a process is thread affinity: you usually want Lightweight's database coroutines to resume on your loop's thread rather than spin up a second loop.
That seam is already abstracted behind two small, public, dependency-injected interfaces in <Lightweight/Async/Executor.hpp>:
Async::IExecutor — where blocking ODBC work runs (the offload target).Async::IResumeScheduler — where a coroutine resumes after a blocking step completes.The library only ever holds references to these; it never owns a loop. To resume Lightweight coroutines on a host event loop, implement IResumeScheduler as a thin adapter that posts the resume onto that loop:
Now database coroutines resume on the host thread, the host keeps owning its loop, and no second pump is needed. If the host already has a worker pool, it can additionally implement IExecutor and be passed as the offload target, so the whole process shares a single pool. Because Async::Task is a plain awaitable, a coroutine in the other runtime can co_await a Lightweight task directly; route its continuation through the same adapter to keep it on the host thread.
The async API is a first-class part of the library — always built, no configuration required.
The executor internals run on NVIDIA stdexec, the reference implementation of std::execution (P2300 — the C++26 executors). ThreadPoolExecutor is an exec::static_thread_pool with each posted item spawned into an exec::async_scope (so its destructor drains every in-flight item before joining); StrandExecutor serializes work over that pool. stdexec is resolved automatically: find_package(stdexec) first (any sufficiently new system/vcpkg install — the build checks stdexec_VERSION against LIGHTWEIGHT_STDEXEC_MIN_VERSION, ≥ 0.9.0), falling back to CPM (NVIDIA/stdexec, tag nvhpc-26.05; the tag and minimum version live in LIGHTWEIGHT_STDEXEC_GIT_TAG / LIGHTWEIGHT_STDEXEC_MIN_VERSION). The stdexec headers are confined to a single translation unit behind a pimpl, so including Lightweight's headers never pulls stdexec in — and an installed Lightweight links it PRIVATE/build-only, so stdexec is not part of the exported interface at all.
Async::Task<T> is a plain coroutine awaitable. You can co_await it directly from another coroutine, drive it with Lightweight::Async::SyncWait(task), or — to flow it into an stdexec sender pipeline — adapt it with Async::AsSender(task):
AsSender maps the Task's outcome onto stdexec's completion channels: a produced value completes set_value, a thrown OperationCancelledError completes set_stopped (matching the cancellation contract above), and any other exception completes set_error carrying a std::exception_ptr. The Task stays lazy — nothing runs until the resulting sender is started (e.g. by sync_wait or an enclosing sender). Because the sender advertises a stopped channel, stdexec::sync_wait returns a std::optional that is disengaged only on cancellation.
Async::SyncWait(task) and stdexec::sync_wait(sender) remain distinct entry points — use the former on a bare Task, the latter on a sender (including one produced by AsSender).
Lifetime in sender pipelines. The record-/builder-lifetime rule above still applies: the operand a record-level async method (e.g.
UpdateAsync) captures must outlive the wholeco_await. When chaining withlet_value, mutate the stage's argument in place (it is kept alive for the child sender) and pass a reference to it, rather than a local that dies when thelet_valuecallback returns. Seesrc/examples/todo/main.cppfor a worked example.
stdexec is a build-time dependency of Lightweight, not part of its installed/exported interface, and Async/Sender.hpp is the one public header that includes the stdexec headers — it is intentionally not included by <Lightweight/Lightweight.hpp> or the C++20 module. To use senders (or this bridge) in your own code, depend on stdexec directly (find_package(stdexec) / link STDEXEC::stdexec) — do not rely on Lightweight to provide its headers transitively.