Version: 0.4 (Group 4 / AimX bridge state removed by design 030) Status: ✅ Implemented Issue: #88 Follow-up: Design 030 — AimX remote-access spawn-free / #114 — ✅ Implemented Last Updated: May 26, 2026 Milestone: M13 — Architectural clean-up
- Summary
- Motivation
- Current Architecture — Audit
- Proposed Design
- Type-System Changes
- Platform-Specific Concerns
- Shutdown / Cancellation
- Connector API impact
- Breaking Changes
- Implementation Plan
- Alternatives Considered
- Decisions
- Out of Scope
Remove Spawn from the Runtime bundle trait and from every R: Spawn bound
in aimdb-core. All futures that would previously be individually spawned
inside build() are instead collected into a Vec<BoxFuture>. A new
AimDb::run() method drives them via FuturesUnordered, blocking until
shutdown.
The intended invariant: all spawn() calls reachable from aimdb-core happen
inside build() after this refactor. A code audit (v0.2 of this doc)
identified five categories of runtime-spawn sites; the table in
Runtime spawn sites — full inventory
catalogues each and assigns it to one of: converted to build-time
collection, deleted as part of this PR, or deferred to a follow-up
issue.
Scope note (v0.2): The v0.1 framing of "one narrow exception" was an oversimplification. The AimX remote-access spawn calls (per-connection handler + per-subscription stream) are non-trivial to remove because they are inherently dynamic-fan-out. Rather than couple their refactor to the
Spawn-trait removal, this design defers them to a separate issue (AimX remote-access portability) so M13 can land as a focused trait-removal change. Within this PR, the AimX path keeps using baretokio::spawninternally — which is fine because AimX is already#[cfg(feature = "std")]-gated. The follow-up issue both removes those spawn calls and prepares AimX for eventual un-gating.
Spawn is currently a supertrait of Runtime:
pub trait Runtime: RuntimeAdapter + TimeOps + Logger + Spawn {}Because AimDb<R> requires R: Spawn, and AimDb<R> is embedded in
TypedRecord<T, R>, Producer<T, R>, Consumer<T, R>, and
RuntimeContext<R>, a Spawn bound propagates to every layer. Adding a new
runtime adapter requires implementing Spawn even if the adapter never needs
to spawn dynamically.
Embassy's task system requires statically-typed futures. To implement Spawn,
aimdb-embassy-adapter heap-allocates every future, type-erases it into a
BoxedFuture, and feeds it into a compile-time-fixed task pool via:
unsafe { Pin::new_unchecked(boxed_future) }The pool size is selected with a feature flag (embassy-task-pool-8/16/32).
Choosing the wrong size panics at runtime. The unsafe block has no audit
trail.
EmbassyAdapter wraps an Option<Spawner>. Because Spawner is !Send,
EmbassyAdapter is !Send without an unsafe impl. The adapter adds it to
satisfy F: Send + 'static demanded by the Spawn trait:
// SAFETY: Embassy executor handles spawner synchronization internally.
unsafe impl Send for EmbassyAdapter {}
unsafe impl Sync for EmbassyAdapter {}WasmAdapter has the same pattern for WASM's single-threaded executor.
Producer<T, R> and Consumer<T, R> also carry unsafe impl Send/Sync
because they hold Arc<AimDb<R>> which transitively requires R: Send + Sync.
| Location | Bound | Role |
|---|---|---|
aimdb-executor/src/lib.rs |
Runtime: … + Spawn |
bundle supertrait |
aimdb-core/src/builder.rs |
AimDb<R: Spawn>, AimDbBuilder<R: Spawn> |
primary database types |
aimdb-core/src/builder.rs |
AimDbInner::get_typed_record_by_key/id<R: Spawn> |
lookup helpers |
aimdb-core/src/typed_record.rs |
TypedRecord<T, R: Spawn> |
per-record storage |
aimdb-core/src/typed_record.rs |
RecordSpawner<T>::spawn_all_tasks<R: Spawn> |
spawn orchestrator |
aimdb-core/src/typed_record.rs |
AnyRecordExt::as_typed<R: Spawn> |
downcast helper |
aimdb-core/src/typed_record.rs |
spawn_producer_service, spawn_consumer_tasks, spawn_transform_task |
direct spawning |
aimdb-core/src/typed_api.rs |
Producer<T, R: Spawn>, Consumer<T, R: Spawn> |
typed handles |
aimdb-core/src/transform/mod.rs |
TransformDescriptor<T, R: Spawn> |
deferred transform |
aimdb-core/src/database.rs |
Database<A: Spawn> |
high-level wrapper |
aimdb-core/src/remote/supervisor.rs |
R: Spawn |
supervisor spawning |
aimdb-core/src/remote/handler.rs |
R: Spawn (×14 bounds) |
handler dispatch |
aimdb-codegen/src/rust.rs |
emits <R: Spawn + 'static> into generated configure_schema |
code generation |
aimdb-persistence/src/builder_ext.rs |
R: Spawn + TimeOps |
persistence builder extension trait |
aimdb-persistence/src/ext.rs |
R: Spawn + 'static (×2) |
persistence trait bounds |
aimdb-persistence/src/query_ext.rs |
R: Spawn + 'static (×2) |
query trait + backend helper |
aimdb-sync/src/handle.rs |
R: Spawn |
sync handle bounds |
aimdb-tokio-adapter/src/connector.rs |
TokioAdapter::spawn_connectors (test-only helper) |
unused helper, deletable |
EmbassyAdapter::spawn(future: F)
│
├─ Box::new(future) // heap-allocate
├─ type-erase → BoxedFuture
└─ TASK_POOL.spawn( // fixed-size array
unsafe { Pin::new_unchecked(boxed) }
)
TASK_POOL_SIZE is selected at compile time via feature flags. If more tasks
are spawned than the pool size, the spawn() call panics. Choosing a pool
that is too large wastes RAM on constrained targets.
impl Spawn for WasmAdapter {
fn spawn<F>(&self, future: F) -> ExecutorResult<()>
where F: Future<Output = ()> + Send + 'static
{
wasm_bindgen_futures::spawn_local(future);
Ok(())
}
}
// Required because Spawn demands F: Send, but WASM is single-threaded:
unsafe impl Send for WasmAdapter {}
unsafe impl Sync for WasmAdapter {}The unsafe exists solely to satisfy the Spawn trait's F: Send bound,
which is vacuously satisfied on single-threaded WASM.
The v0.1 audit listed six build-time spawn sites and claimed they were the total. A line-by-line code search produced the following complete picture. Sites are grouped by disposition (what this PR does with them).
Group 1 — Build-time spawns converted to returned futures (in scope).
| Call site | File | One future per… |
|---|---|---|
spawn_producer_service |
aimdb-core/src/typed_record.rs:1228 |
.source() |
spawn_consumer_tasks |
aimdb-core/src/typed_record.rs:1155 |
.tap() |
spawn_transform_task |
aimdb-core/src/typed_record.rs:881 |
.transform() / .transform_join() |
| on_start tasks | aimdb-core/src/builder.rs:977,988 |
.on_start() |
| Connector outbound publishers | per-connector spawn_outbound_publishers() |
.link_to() route |
| Connector infrastructure | MQTT spawn_event_loop(), KNX spawn_connection_task(), WS start_server() |
one per connector |
| Remote supervisor entry point | aimdb-core/src/builder.rs → supervisor.rs:108 |
one per with_remote_access() |
Group 2 — Runtime spawn that hoists to build time (in scope, new in v0.2).
| Call site | File | Why it's actually a build-time fan-out |
|---|---|---|
| Join transform forwarders | aimdb-core/src/transform/join.rs:329 |
inputs.len() is fixed at transform_join() registration; lazy spawn is incidental |
The forwarder count is statically known when the JoinPipeline is built.
Step 3a converts the lazy spawn into build-time collection alongside the
join transform future itself.
Group 3 — Runtime spawn deleted as part of this PR (in scope).
| Item | File | Disposition |
|---|---|---|
AimDb::spawn_task public method |
aimdb-core/src/builder.rs:1096 |
Delete. With Spawn gone there is no portable backing primitive; the method has no internal callers. |
TokioAdapter::spawn_connectors |
aimdb-tokio-adapter/src/connector.rs:54 |
Delete. Test-only helper, no production callers. |
BufferOps::spawn_dispatcher |
aimdb-tokio-adapter/src/buffer.rs:205 |
Keep (test-only utility), but mark for removal in a follow-up tidy if no external user adopts it. |
Group 4 — Runtime spawn deferred to follow-up issue (now resolved).
All three sites were addressed by the AimX spawn-free follow-up
(design 030, issue
#114). Each was
converted to a nested FuturesUnordered driven by tokio::select! { biased; }; cancellation collapsed to dropping the future.
| Call site | File | Resolution |
|---|---|---|
| AimX per-connection handler | aimdb-core/src/remote/supervisor.rs |
Supervisor owns a FuturesUnordered<BoxFuture>; accepted connections are pushed in. |
| AimX per-subscription stream | aimdb-core/src/remote/handler.rs + builder.rs |
New stream_record_updates helper returns a Stream; per-conn FuturesUnordered holds one future per record.subscribe. subscribe_record_updates deleted. |
| WebSocket client reconnect | aimdb-websocket-connector/src/client/connector.rs |
Six tokio::spawn sites collapsed into one connector future that owns a FuturesUnordered; reconnect watcher sends NewLoops over an mpsc rather than spawning. |
Group 5 — External / out-of-codebase (informational).
wasm_bindgen_futures::spawn_local calls in aimdb-wasm-adapter/src/{ws_bridge.rs,bindings.rs} are WASM-runtime glue invoked by JS callbacks; they are outside the Spawn trait surface and unaffected. Example binaries and tests that call tokio::spawn directly are user code, not core.
Groups 1, 2, and 3 cover every runtime.spawn(...) call within
aimdb-core and every connector — once they land, the Spawn trait has no
internal callers. Group 4 retained bare tokio::spawn calls inside
aimdb-core/src/remote/ as a deliberate bridge state in this PR; those
calls did not depend on the trait (they called Tokio directly through
#[cfg(feature = "std")]), so the trait could be deleted cleanly. The
follow-up (design 030) has since
removed every Group 4 spawn call.
// Build — returns a (handle, runner) pair
let (db, runner) = AimDbBuilder::new()
.runtime(adapter)
.configure::<Temperature>("sensor.temp", |reg| { ... })
.build()
.await?;
// db is a plain Clone-able handle; clone freely before starting the runner
let handle = db.clone();
// Run — drives all futures collected during build(), blocks until shutdown
runner.run().await;build() returns (AimDb<R>, AimDbRunner). AimDb<R> is an ordinary
clone-able handle (same as today). AimDbRunner is a non-Clone struct that
owned the collected futures; it has no Arc or Mutex wrapping.
AimDbRunner::run() takes self by value, consuming the futures vec.
Replace every runtime.spawn(future) call with:
futures.push(future);where futures: Vec<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>.
At the end of build(), the vec is wrapped in AimDbRunner and returned
alongside the AimDb<R> handle:
/// Non-Clone runner returned by build().
/// Owns the complete set of futures that drive the database.
pub struct AimDbRunner {
futures: Vec<BoxFuture<'static, ()>>,
}
// AimDb<R> itself is unchanged — no futures field, no Mutex.
pub struct AimDb<R: RuntimeAdapter + 'static> {
inner: Arc<AimDbInner>,
runtime: Arc<R>,
// ... profiling, etc. — exactly as today
}use futures::stream::{FuturesUnordered, StreamExt};
impl AimDbRunner {
pub async fn run(self) {
if self.futures.is_empty() {
return; // nothing to drive
}
let mut set = FuturesUnordered::new();
for f in self.futures {
set.push(f);
}
// Drive all futures to completion (normally: forever, until shutdown)
while set.next().await.is_some() {}
}
}FuturesUnordered polls each future cooperatively. Tasks that finish early
(e.g. a one-shot on_start) are dropped; tasks that run indefinitely (producer
services, consumer loops) keep the set alive.
ConnectorBuilder::build() currently spawns its own tasks as a side effect.
With this change, connectors must return their futures instead of spawning
them. The ConnectorBuilder trait becomes:
pub trait ConnectorBuilder<R: RuntimeAdapter> {
async fn build(
self: Box<Self>,
db: &AimDb<R>,
) -> DbResult<Vec<BoxFuture<'static, ()>>>;
}AimDbBuilder::build() collects the returned futures and adds them to the
accumulator. This is a breaking change to the ConnectorBuilder trait
(connector authors must update build()).
Each connector currently has two distinct categories of futures, both of which must be returned:
-
Outbound publisher futures — one per
link_to()route, currently spawned viaruntime.spawn()insidespawn_outbound_publishers(). These subscribe to a typed record and publish serialised values to the external system. -
Infrastructure futures — one per connector instance, currently spawned via bare
tokio::spawn()inside internal helpers (see audit table above). Converting these is covered in Step 6b.
The current ConnectorBuilder::build() returns Arc<dyn Connector>, but
builder.rs already discards this value immediately:
let _connector = builder.build(&db).await?;. The Connector::publish()
method — used for direct programmatic publishing — is already inaccessible
through the AimDbBuilder public API. Changing the return type to
Vec<BoxFuture> causes no behavioural regression here.
Connector implementations keep their data alive via Arc clones captured
inside the returned futures (e.g. Arc<AsyncClient> in MQTT,
mpsc::Sender<KnxCommand> in KNX) — not via the connector object itself.
The KNX connector creates an mpsc::channel inside spawn_connection_task():
the receiver is captured by the connection task, and the sender is cloned into
each outbound publisher task. In the new model the channel must be created
before either set of futures is constructed:
// 1. Create channel first
let (cmd_tx, cmd_rx) = mpsc::channel(16);
// 2. Connection future captures cmd_rx
let connection_future: BoxFuture<'static, ()> = Box::pin(async move {
// UDP connection + reconnect loop, reads from cmd_rx
});
// 3. Outbound publisher futures each clone cmd_tx
let publisher_futures: Vec<BoxFuture<'static, ()>> = routes
.into_iter()
.map(|route| {
let tx = cmd_tx.clone();
Box::pin(async move { /* subscribe → serialize → tx.send() */ }) as BoxFuture<'static, ()>
})
.collect();
// 4. All futures returned together
Ok(std::iter::once(connection_future).chain(publisher_futures).collect())This ordering is already implicit in the current code and must be preserved explicitly when refactoring.
supervisor::spawn_supervisor() currently calls runtime.spawn(supervisor_loop).
In the new model it returns the supervisor future instead:
pub fn build_supervisor<R: RuntimeAdapter>(
db: Arc<AimDb<R>>,
config: AimxConfig,
) -> DbResult<BoxFuture<'static, ()>>Target state achieved. The bridge state described in v0.3 of this
design has been removed by the AimX spawn-free follow-up
(design 030, issue
#114). The supervisor
now pushes per-connection handler futures onto its own
FuturesUnordered; the handler does the same with per-subscription
futures backed by a Stream-returning helper (stream_record_updates);
AimDb::subscribe_record_updates is deleted. No tokio::spawn remains
in aimdb-core/src/remote/.
The AimX path is now runtime-agnostic in shape (still #[cfg(feature = "std")]-gated for transport reasons). Lifting the std gate itself
remains a separate, larger effort; see design 030 §"Out of Scope" for
the remaining work.
AimDb::spawn_task is a public
convenience that forwards to R::spawn. With the trait removed there is
no portable backing primitive, and the method has no internal callers.
Deleted in this PR (Step 4). Downstream code that wants post-build task
creation must either:
- Register the future via
on_start()(preferred — collected bybuild()). - Place a
FuturesUnorderedinside its own future and push children there.
There is no third option. This is intentional: the surface area we are removing is exactly the surface area that made the trait viral.
| Type / fn | Before | After |
|---|---|---|
Runtime supertrait |
RuntimeAdapter + TimeOps + Logger + Spawn |
RuntimeAdapter + TimeOps + Logger |
AimDb<R> |
R: Spawn + 'static |
R: RuntimeAdapter + 'static |
AimDbBuilder<R> |
R: Spawn + 'static |
R: RuntimeAdapter + 'static |
TypedRecord<T, R> |
R: Spawn + 'static |
R: 'static |
Producer<T, R> |
R: Spawn + 'static |
R: 'static |
Consumer<T, R> |
R: Spawn + 'static |
R: 'static |
TransformDescriptor<T, R> |
R: Spawn + 'static |
R: 'static |
RecordSpawner::spawn_all_tasks<R> |
R: Spawn + 'static |
removed (see below) |
AnyRecordExt::as_typed<R> |
R: Spawn + 'static |
R: 'static |
Database<A> |
A: Spawn + 'static |
A: RuntimeAdapter + 'static |
remote/handler.rs (×14) |
R: Spawn + 'static |
R: RuntimeAdapter + 'static |
remote/supervisor.rs |
R: Spawn + 'static |
R: RuntimeAdapter + 'static |
impl Clone for AimDb<R> |
R: Spawn + 'static |
R: RuntimeAdapter + 'static |
aimdb-codegen/src/rust.rs emitted code |
<R: Spawn + 'static> |
<R: RuntimeAdapter + 'static> (golden tests update) |
aimdb-persistence (3 files) |
R: Spawn (+ TimeOps) |
R: RuntimeAdapter (+ TimeOps) |
aimdb-sync/src/handle.rs |
R: Spawn |
R: RuntimeAdapter |
RecordSpawner<T>::spawn_all_tasks<R>() currently calls runtime.spawn() for
each task. It is renamed and refactored to collect futures instead:
pub struct RecordFutureCollector<T> { _phantom: PhantomData<T> }
impl<T: Send + Sync + 'static + Debug + Clone> RecordFutureCollector<T> {
pub fn collect_all_futures<R: 'static>(
record: &dyn AnyRecord,
db: &Arc<AimDb<R>>,
record_key: &str,
) -> DbResult<Vec<BoxFuture<'static, ()>>> {
let mut futures = Vec::new();
// ... downcast, collect producer, transform, consumer futures ...
Ok(futures)
}
}Similarly, TypedRecord::spawn_producer_service → collect_producer_future,
spawn_consumer_tasks → collect_consumer_futures,
spawn_transform_task → collect_transform_future.
Producer<T, R> and Consumer<T, R>:
Currently marked unsafe impl Send/Sync because R: Spawn does not guarantee
R: Send + Sync on all platforms (notably Embassy and WASM). After this
change, R: RuntimeAdapter (which is Send + Sync + 'static), and
Arc<AimDb<R>> is Send + Sync if R: Send + Sync. Since RuntimeAdapter
already requires Send + Sync + 'static, Producer<T, R> and Consumer<T, R>
will auto-derive Send + Sync without unsafe. Remove the unsafe impls.
EmbassyAdapter:
EmbassyAdapter currently holds Option<Spawner>. Spawner is !Send.
With Spawn removed, the Spawner field is no longer needed (it was only
used in impl Spawn for EmbassyAdapter). Remove the spawner field.
After removing spawner, EmbassyAdapter becomes a simple struct holding
only an Option<&'static Stack<'static>> (the network stack, gated by
embassy-net-support).
Implementation note (v0.3). The earlier draft assumed
&'static Stack<'static>would beSend + Syncand theunsafe implblocks could be deleted outright. In practiceembassy_net::Stackcontains aRefCelland is!Sync, so the adapter still needs:// SAFETY: Embassy executors run cooperatively on a single core. The // `embassy_net::Stack` (when present) is `!Sync` only because of its // internal `RefCell`; in a single-threaded executor no concurrent access // is possible. unsafe impl Send for EmbassyAdapter {} unsafe impl Sync for EmbassyAdapter {}The justification changes (single-threaded cooperative executor, no longer "satisfy the
Spawntrait'sF: Sendbound") and the audit-trail comment is rewritten accordingly, but theunsafeblocks themselves stay. The netunsafe-block count on the adapter goes from [Send, Sync, Pin::new_unchecked in task pool] to just [Send, Sync] — the task-poolPin::new_uncheckedis the one that actually disappears.
The new_with_spawner() constructor is removed (breaking change — see
Breaking Changes).
new_with_network(spawner, network) (runtime.rs:162)
currently also takes a Spawner. With Spawn removed the parameter is dead
weight. The signature changes to new_with_network(network). This breaks
three example binaries and any aimdb-pro snippets that pass spawner:
| Affected file | Current call |
|---|---|
examples/embassy-knx-connector-demo/src/main.rs:253 |
EmbassyAdapter::new_with_network(spawner, stack) |
examples/embassy-mqtt-connector-demo/src/main.rs:317 |
same |
examples/weather-mesh-demo/weather-station-gamma/src/main.rs:259 |
same |
| aimdb-pro UI docs (llms-full.txt, 04-deployment.md) | same |
WasmAdapter:
WasmAdapter is already a ZST (#[derive(Clone, Copy, Debug)] with no
fields). With Spawn removed it no longer needs the unsafe impl. Since it is
a ZST it auto-derives Send + Sync. Remove the unsafe impl.
Embassy connector futures:
Embassy connector implementations (e.g. the Embassy variant of
aimdb-mqtt-connector) currently wrap their outbound publisher futures in
SendFutureWrapper to satisfy the F: Send + 'static bound imposed by
impl Spawn for EmbassyAdapter. With Spawn removed, runtime.spawn() calls
disappear — but Vec<BoxFuture<'static, ()>> (where
BoxFuture = Pin<Box<dyn Future + Send + 'static>>) still requires Send.
Embassy futures are !Send. Embassy connector implementations must therefore
still wrap their returned futures in SendFutureWrapper before pushing them
to the Vec. The unsafe burden shifts from the adapter to the connectors —
the total number of unsafe impl blocks decreases, but is not reduced to zero.
Producer<T, R> and Consumer<T, R> currently hold Arc<AimDb<R>> and bind
R for type inference at call sites (producer.produce(val).await). After the
bound change, R is still carried but is now only constrained to R: 'static.
The _phantom: PhantomData<T> field remains as-is.
Whether to collapse R entirely (making Producer<T>) is a larger question
deferred to a follow-up refactor — it would change all public API signatures.
FuturesUnordered from futures-util is the natural choice. Tokio users call:
tokio::select! {
_ = runner.run() => {},
_ = shutdown_signal() => {},
}No adapter changes beyond removing impl Spawn for TokioAdapter.
FuturesUnordered from futures-util compiles in no_std + alloc mode
(requires futures-util with default-features = false, features = ["alloc"]).
Embassy main becomes:
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let adapter = EmbassyAdapter::new().unwrap();
let db = AimDbBuilder::new()
.runtime(Arc::new(adapter))
.configure::<Temperature>(...)
.build()
.await
.unwrap();
runner.run().await; // drives FuturesUnordered inside the Embassy main task
}Embassy's cooperative scheduler handles the FuturesUnordered::next().await
poll loop the same way it handles any await point. This replaces the task
pool entirely.
Memory profile: Each boxed future occupies one heap allocation. The total number of futures is bounded by the database configuration, same as today. Heap usage is therefore unchanged.
Pool size feature flags (embassy-task-pool-8/16/32) are deleted.
Cooperative-scheduling implication. Today each AimDB future runs in its
own Embassy task with its own stack: a producer that blocks between awaits
only starves itself. After this change, all collected futures share a
single Embassy task's stack and yield budget. In practice this is
benign — AimDB futures are async I/O loops that yield frequently — but it
is a real semantic shift. If an application registers a future that does
heavy synchronous work between awaits, that work now blocks every other
AimDB future, not just itself. Document this in the user-facing migration
note. (The eventual lift of std gating on AimX, when it lands via the
follow-up issue, will further amplify this — at that point the AimX
supervisor lives in the same shared stack.)
WASM's single-threaded runtime presents no new concerns because
FuturesUnordered works on single-threaded executors. runner.run() is
awaited inside the WASM main function or a wasm_bindgen_futures::spawn_local
root.
FuturesUnordered::next() returns None only when all futures have completed.
Producer services and consumer loops are infinite by design; run() therefore
blocks for the application's lifetime.
Cancellation strategies:
- Tokio
CancellationToken— wrapdb.run()inselect!with a cancellation token. Producer/consumer tasks should check the token in their loop body. - CTRL-C / signal handler — same
select!pattern withtokio::signal::ctrl_c(). - Embassy — cooperative via task cancellation or a shared
AtomicBoolstop flag.
The design does not prescribe a shutdown mechanism; that is the application's
responsibility. A future enhancement could add a db.shutdown() method that
drops the futures set.
Connectors that implement ConnectorBuilder must be updated:
Before:
async fn build(self: Box<Self>, db: &AimDb<R>) -> DbResult<Box<dyn Connector>> {
// spawns tasks internally via runtime.spawn()
runtime.spawn(my_task_future);
Ok(Box::new(MyConnector { ... }))
}After:
async fn build(
self: Box<Self>,
db: &AimDb<R>,
) -> DbResult<Vec<BoxFuture<'static, ()>>> {
// returns futures for AimDbBuilder to collect
Ok(vec![Box::pin(my_task_future)])
}Affected connectors: aimdb-mqtt-connector (Tokio and Embassy), aimdb-knx-connector,
aimdb-websocket-connector, and aimdb-pro call sites.
Beyond the connector trait, three additional crates carry R: Spawn bounds
that must be loosened to R: RuntimeAdapter (mechanical, no behaviour change):
aimdb-codegen(rust.rs:560,734,1253) — the schema-codegen emitsuse aimdb_executor::Spawn;and<R: Spawn + 'static>into generatedconfigure_schemafunctions. Update the emitter and the two golden tests at rust.rs:1836,1941.aimdb-persistence(builder_ext.rs:23, ext.rs:19,32, query_ext.rs:59,68) — fiveR: Spawnbounds across three files.aimdb-sync(handle.rs) — sync-API handle types carryR: Spawn.
None of these crates call runtime.spawn; they only propagate the bound.
Removing the bound is a find-replace.
| Area | Change |
|---|---|
| Public API | db.run().await must be called after build() — tasks do not start until run() |
Runtime supertrait |
Spawn removed — custom adapters no longer need to implement it |
Spawn trait |
Deleted entirely from aimdb-executor (SpawnToken associated type goes with it) |
ExecutorError::SpawnFailed |
Variant removed |
AimDb::spawn_task |
Public method removed — use on_start() or nested FuturesUnordered |
EmbassyAdapter::new_with_spawner |
Constructor removed |
EmbassyAdapter::new_with_network(spawner, network) |
Signature changes to (network) — spawner arg removed |
EmbassyAdapter feature flags |
embassy-task-pool-8/16/32 removed |
ConnectorBuilder trait |
build() now returns Vec<BoxFuture> instead of Box<dyn Connector> |
TokioAdapter::spawn_connectors |
Removed (test-only helper, unused) |
| Generated code (codegen) | configure_schema<R: Spawn + 'static> → <R: RuntimeAdapter + 'static> — regenerate downstream |
aimdb-persistence, aimdb-sync trait bounds |
R: Spawn → R: RuntimeAdapter on public traits |
spawn_fns in AimDbBuilder |
internal — no public API change, but internal structure changes |
Listed in dependency order. Each step should pass make check before the next
begins.
File: aimdb-executor/src/lib.rs
- Remove
Spawnfrom theRuntimesupertrait. - Delete the
Spawntrait entirely fromaimdb-executor. - Remove the
SpawnFailedvariant fromExecutorError. - Add
futures-utildependency withdefault-features = false, features = ["alloc"].
Test: cargo check --all-features + cargo check --target thumbv7em-none-eabihf.
Files: aimdb-core/src/typed_api.rs, aimdb-core/src/typed_record.rs,
aimdb-core/src/transform/mod.rs
- Remove
R: Spawnbound fromProducer<T, R>,Consumer<T, R>,RecordRegistrar. - Remove
unsafe impl Send/SyncfromProducer<T, R>andConsumer<T, R>— verify auto-derivation works. - Remove
R: Spawnbound fromTypedRecord<T, R>andTransformDescriptor<T, R>. - Remove
R: Spawnbound fromAnyRecordExt::as_typed<R>.
File: aimdb-core/src/typed_record.rs
Rename and refactor:
RecordSpawner<T>→RecordFutureCollector<T>spawn_all_tasks→collect_all_futures(returnsVec<BoxFuture>)spawn_producer_service→collect_producer_future(returnsOption<BoxFuture>)spawn_consumer_tasks→collect_consumer_futures(returnsVec<BoxFuture>)spawn_transform_task→collect_transform_future(returnsOption<BoxFuture>)
Each method constructs the future and returns it rather than calling
runtime.spawn().
File: aimdb-core/src/transform/join.rs
run_join_transform (join.rs:329)
currently calls runtime.spawn(forwarder_future) lazily inside the
already-running transform task. The forwarder count equals inputs.len(),
which is known when the JoinPipeline is registered.
- Change
JoinPipeline::into_descriptor()to return both the transform future and the forwarder futures: e.g.TransformDescriptor { task_future, fanin_futures: Vec<BoxFuture<'static, ()>> }. - Construct the
JoinTriggerqueue and forwarder futures at descriptor construction time (build phase), not insiderun_join_transform. collect_transform_future(Step 3) appends bothtask_futureand everyfanin_futureto the accumulator vec.- Delete the
runtime.spawn(...)call insiderun_join_transform.
File: aimdb-core/src/builder.rs
- Add
AimDbRunnerstruct with afutures: Vec<BoxFuture<'static, ()>>field. - Change
build()return type fromDbResult<AimDb<R>>toDbResult<(AimDb<R>, AimDbRunner)>. - In
build(), replace eachruntime.spawn(f)withfutures_acc.push(f). - At end of
build(), wrap the accumulated vec inAimDbRunnerand return it alongside theAimDb<R>handle. - Implement
AimDbRunner::run(self)usingFuturesUnordered. AimDb<R>itself gains no new fields — it remains a plain clone-able handle.- Remove
R: Spawnbound fromAimDb<R>,AimDbBuilder<R>,AimDbInner::get_typed_record_by_key/id, and the manualimpl Clone for AimDb<R>at builder.rs:1037. - Delete
AimDb::spawn_task(builder.rs:1096). No internal callers; downstream callers migrate toon_start()or nestedFuturesUnordered. - Collapse the
std/no_stdon_startbifurcation: the two type aliases at builder.rs:32-47 are already identical — the bifurcation is vestigial. Unify into a singletype StartFnType<R> = Box<dyn FnOnce(Arc<R>) -> BoxFuture<'static, ()> + Send>;and remove the#[cfg(feature = "std")]split. No closure-bound change needed.
Files: aimdb-core/src/remote/supervisor.rs,
aimdb-core/src/remote/handler.rs, aimdb-core/src/builder.rs
- Rename
spawn_supervisor→build_supervisor_future; returnDbResult<BoxFuture<'static, ()>>instead of spawning. - Remove
R: Spawnbounds from all 14 handler function signatures; replace withR: RuntimeAdapter. build()inbuilder.rscallsbuild_supervisor_future()and pushes the returned future to the accumulator.AimDb::subscribe_record_updates(builder.rs:1409) currently callsruntime.spawn(...). Rewrite to calltokio::spawndirectly under#[cfg(feature = "std")]. This is the bridge state — the full nested-FuturesUnorderedrewrite is deferred to the follow-up issue.- Bare
tokio::spawncalls atsupervisor.rs:122andhandler.rs:1042remain unchanged in this PR; they are addressed in the follow-up.
Files: aimdb-core/src/connector.rs, all connector crates
- Update
ConnectorBuilder::build()return type toDbResult<Vec<BoxFuture<'static, ()>>>. - Remove
R: Spawnbound from theConnectorBuilder<R>trait definition. - Update
builder.rsto extend the accumulator with the returned Vec.
Each Tokio connector has one permanent infrastructure future currently spawned
via bare tokio::spawn. Convert each to construct and return a BoxFuture
instead:
- MQTT:
spawn_event_loop()→build_event_loop_future()— return therumqttcEventLoop poll loop as aBoxFutureinstead of callingtokio::spawn. - KNX:
spawn_connection_task()→build_connection_future()— return(BoxFuture<'static, ()>, mpsc::Sender<KnxCommand>). TheSenderis then cloned into outbound publisher futures (see KNX channel ownership ordering in the Connector futures section). - WebSocket:
start_server()→build_server_future()— return theaxum::serve(...)loop as aBoxFuture. Per-connection tasks that Axum spawns internally remain astokio::spawn— same category as the remote supervisor's per-connection handlers.
Replace spawn_outbound_publishers() with collect_outbound_futures() on each
connector implementation, returning Vec<BoxFuture> instead of calling
runtime.spawn() per route.
Embassy connector implementations must wrap returned futures in
SendFutureWrapper before pushing them to the Vec (Embassy futures are !Send
but BoxFuture<'static, ()> requires Send). This is the same pattern already
used on the Embassy path — the wrapping moves from the spawn call site to the
collection point.
File: aimdb-tokio-adapter/src/runtime.rs
- Remove
impl Spawn for TokioAdapter.
File: aimdb-embassy-adapter/src/runtime.rs
- Remove
impl Spawn for EmbassyAdapter. - Remove
spawner: Option<Spawner>field. - Remove
new_with_spawner()constructor. - Remove
generic_task_runner,BoxedFuture, task pool declarations. - Remove
embassy-task-pool-8/16/32Cargo features. - Remove
unsafe impl Send for EmbassyAdapterandunsafe impl Sync for EmbassyAdapter. - Remove
build.rslogic that configures task pool size (if applicable).
File: aimdb-wasm-adapter/src/runtime.rs
- Remove
impl Spawn for WasmAdapter. - Remove
unsafe impl Send for WasmAdapterandunsafe impl Sync for WasmAdapter.
File: aimdb-codegen/src/rust.rs
- Replace emitted
use aimdb_executor::Spawn;with whatever the new bundle exports (or drop it —AimDbBuilder<R>no longer needs an explicit bound). - Change emitted
<R: Spawn + 'static>to<R: aimdb_executor::RuntimeAdapter + 'static>inconfigure_schemasignatures (lines 560, 734, 1253). - Update the two golden-string tests at lines 1836 and 1941.
aimdb-persistence(builder_ext.rs,ext.rs,query_ext.rs): replace everyR: SpawnwithR: RuntimeAdapter. No runtime calls change.aimdb-sync(handle.rs): same find-replace.
aimdb-tokio-adapter/src/connector.rs: delete the helper and its tests.
Test-only, no external callers.
Update all examples to add runner.run().await after build(). Update
aimdb-pro demo binaries, connectors, and any internal tooling that calls
build(). Three Embassy examples plus aimdb-pro docs require an
EmbassyAdapter::new_with_network(spawner, stack) → new_with_network(stack)
edit:
examples/embassy-knx-connector-demo/src/main.rsexamples/embassy-mqtt-connector-demo/src/main.rsexamples/weather-mesh-demo/weather-station-gamma/src/main.rs- aimdb-pro UI docs (
llms-full.txt,04-deployment.md)
- Add entry to
CHANGELOG.mddocumenting breaking changes. - Update
README.mdquickstart snippet. - Update doc comments on
build()andrun().
build() could return a JoinSet<()> that callers await. This keeps the
Spawn trait but adds a handle-based shutdown mechanism. Rejected because
JoinSet is Tokio-specific and doesn't help Embassy or WASM.
Embassy already uses join_array / select_array. A macro could call the
right primitive per platform. Rejected because it requires platform-specific
run() implementations and adds conditional compilation complexity.
Collapsing R out of the typed handles would simplify the API further.
Deferred — it is a larger change to all connector and user-facing APIs.
Can be done in a follow-up milestone once this foundation is in place.
Expose db.spawn(future) for callers who legitimately need post-build task
spawning (e.g. a custom connector). Deferred — no current use case inside
the AimDB codebase requires it. Can be re-introduced if a concrete need arises,
backed by an explicit Spawn impl on the adapter.
A handle returned from build() that can submit new futures into the
running FuturesUnordered via an unbounded mpsc. Rejected — adds API
surface (AimDbSpawner, channel polling in run()) for a use case (AimX
per-connection / per-subscription fan-out) that has a strictly cleaner
local solution: nested FuturesUnordered inside the supervisor future
itself. See Out of Scope.
Combine the AimX portability refactor with the trait removal. Deferred —
two unrelated changes, doubles the review surface, and the AimX rewrite
touches subscription cancellation semantics (today: oneshot::Sender per
subscription; after: drop the future). Cleaner as a focused follow-up.
See Out of Scope.
All design questions raised during review have been resolved:
-
build()return type → Tuple(AimDb<R>, AimDbRunner). Consistent with Rust channel idioms (mpsc::channel,oneshot::channel). No extra type to import; destructuring at every call site is idiomatic. -
Spawntrait retention → Delete entirely. This is already a semver-breaking release; no deprecation cycle is needed. Any downstream crate that implementedSpawnmust update for the other breaking changes regardless. -
ExecutorError::SpawnFailedvariant → Remove. WithSpawndeleted,SpawnFailedis unreachable. Remove it in the same commit as the trait deletion (Step 1). -
Database<A>promotion → Defer. Drop theA: Spawnbound as part of this milestone but keepDatabase<A>as a convenience wrapper. Renaming the primary user-facing type is a larger mechanical change with no functional benefit at this stage. -
Connector
build()return type →Vec<BoxFuture>only.builder.rsalready discardsArc<dyn Connector>today (let _connector = ...). No behavioural regression. Introspection handles can be reconsidered in a future milestone when a concrete use case exists. -
on_startstd/no_std bifurcation → Collapse. Unify into a singleStartFnType<R>alias (see Step 4 for details). The two existing aliases are already byte-for-byte identical; this is a pure simplification. -
Join-transform forwarders → Hoist to build time.
inputs.len()is statically known whentransform_join()registers the pipeline. The lazyruntime.spawn(forwarder)insiderun_join_transformbecomes build-time collection (Step 3a). This keeps the "no spawn reachable fromaimdb-coreafterbuild()" invariant clean — modulo the AimX exception below. -
AimX remote-access portability → Defer to follow-up issue. The supervisor and handler currently call bare
tokio::spawnfor per-connection and per-subscription tasks. These do not depend on theSpawntrait, so they can stay as-is for this PR (AimX isstd-gated). A separate issue replaces them with nestedFuturesUnordereddriven byselect_biased!, which makes the AimX path runtime-agnostic and is the prerequisite for eventually un-gating AimX fromstd. See Out of Scope. -
AimDb::spawn_task→ Delete. Public convenience method with no internal callers. After the trait removal there is no portable backing primitive. Downstream callers migrate toon_start()(build-time collection) or to a privateFuturesUnorderedinside their own future. No deprecation cycle; already breaking release.
The following are explicitly not part of this PR / issue #88:
Originally deferred to a follow-up; landed via
design 030 /
issue #114. All three
bridge-state tokio::spawn sites in aimdb-core/src/remote/ were
replaced with nested FuturesUnordered; subscribe_record_updates was
deleted in favour of a Stream-returning helper; per-subscription
oneshot cancel channels were replaced with Arc<Notify> notifies for immediate unsubscribe.
Originally deferred alongside the AimX follow-up; resolved in the same
PR. The six tokio::spawn sites in
aimdb-websocket-connector/src/client/connector.rs
collapsed into one connector future that owns a FuturesUnordered; the
reconnect watcher sends NewLoops over an mpsc rather than spawning.
Already deferred (Alternative C). Touches every public API signature and is a larger surface change with no functional benefit on top of this refactor.