Skip to content

88 replace spawn trait with futuresunordered#117

Draft
lxsaah wants to merge 11 commits into
mainfrom
88-replace-spawn-trait-with-futuresunordered
Draft

88 replace spawn trait with futuresunordered#117
lxsaah wants to merge 11 commits into
mainfrom
88-replace-spawn-trait-with-futuresunordered

Conversation

@lxsaah
Copy link
Copy Markdown
Contributor

@lxsaah lxsaah commented May 25, 2026

Summary

Two stacked architectural clean-ups in a single PR:

  1. M13 — Spawn trait removed across the workspace. Every future the database needs (.source() / .tap() / .transform() tasks, on_start hooks, connector loops, the remote-access supervisor, join fan-in forwarders) is now collected at build time into an AimDbRunner and driven by a single FuturesUnordered inside runner.run().await. AimDbBuilder::build() returns (AimDb<R>, AimDbRunner).
  2. M14 — Runtime parameter R removed from Producer<T> / Consumer<T>. Both types now pre-resolve their record at build time and carry an Arc<dyn WriteHandle<T>> / Arc<dyn DynBuffer<T>> directly. Hot-path produce() collapses from HashMap lookup + TypeId check + downcast to one virtual call. produce() becomes sync + infallible; subscribe() becomes infallible.

Together they eliminate the Spawn/R propagation that previously threaded through every typed handle, transform, registrar, and connector builder in aimdb-core.

Motivation

  • Spawn permeated the type system. Because AimDb<R> required R: Spawn, and AimDb<R> was embedded in every typed handle, the bound propagated everywhere. Adding a new runtime adapter required impl Spawn even when nothing dynamic was being spawned.
  • Embassy task-pool workaround was unsound-adjacent. The Embassy adapter heap-allocated futures, type-erased them into BoxedFuture, and fed them into a compile-time-fixed task pool via unsafe { Pin::new_unchecked(...) }. With no Spawn, the pool, the BoxedFuture, the unsafe cast, and the embassy-task-pool-{8,16,32} Cargo features all disappear.
  • Producer::produce() did real work before pushing. Every call performed a HashMap<StringKey, RecordId> probe, a bounds check, a TypeId equality check, and a dyn AnyRecord → &TypedRecord<T, R> downcast — on the hot path, at the producer's produce() rate. A 1 kHz sensor was paying that cost 1000×/s for no benefit. M14 pre-resolves the handle at build time so the steady-state path is one virtual call into the buffer.

What changed

aimdb-core

  • AimDbBuilder::build() returns (AimDb<R>, AimDbRunner). No background work runs until runner.run().await is polled.
  • AimDb::spawn_task deleted. Migrate to on_start() (collected at build) or to a private FuturesUnordered inside your own future.
  • Runtime bundle trait no longer requires Spawn. R: Spawn bounds replaced by R: RuntimeAdapter across Producer, Consumer, TypedRecord, TransformDescriptor, RecordRegistrar, RecordT, AnyRecordExt::as_typed, remote handler/supervisor, Database<A>.
  • Producer<T, R>Producer<T>; Consumer<T, R>Consumer<T>. Backed by a new crate-private WriteHandle<T> trait (aimdb-core/src/buffer/writer.rs) pre-bound to the record's Arc<dyn DynBuffer<T>> + snapshot mutex + metadata tracker.
  • Producer::produce is now sync + infallible. producer.produce(x).await?producer.produce(x);. The ProducerTrait::produce_any type-erased surface stays Result/async for the downcast.
  • Consumer::subscribe is now infallible. let Ok(reader) = consumer.subscribe() else { ... }let reader = consumer.subscribe();.
  • AimDb::producer<T>(key) / AimDb::consumer<T>(key) now return DbResult<…> — they resolve the typed record up front, so callers must add ?.
  • Producer::key() removed. Capture the record key at the registration site instead.
  • .tap() on a record with no .buffer(...) surfaces as MissingConfiguration at build time (was a deferred subscribe-time error).
  • Internals renamed: RecordSpawner<T>RecordFutureCollector<T>; spawn_all_taskscollect_all_futures; spawn_consumer_tasks/spawn_producer_service/spawn_transform_taskcollect_*_futures.
  • Join transforms hoist per-input fan-in forwarders to build time. JoinPipeline::into_descriptor() now returns CollectedTransform { task_future, fanin_futures }; the lazy runtime.spawn(forwarder) inside run_join_transform is gone.
  • unsafe impl Send/Sync blocks on Producer<T, R> / Consumer<T, R> deleted — they auto-derive now that R is gone.

aimdb-executor

  • Spawn trait deleted (including SpawnToken and ExecutorError::SpawnFailed).
  • futures-util added as a regular (alloc-only) dependency — provides FuturesUnordered for the new runner.
  • JoinFanInRuntime supertrait relaxed from Spawn to RuntimeAdapter.

aimdb-tokio-adapter

  • impl Spawn for TokioAdapter deleted. Adapter is now RuntimeAdapter + TimeOps + Logger.
  • spawn_connectors test-only helper and connector module deleted. Outbound connector futures are collected by ConnectorBuilder::build() now.
  • Generated extension trait emits Producer<T> / Consumer<T> (no , TokioAdapter).

aimdb-embassy-adapter

  • impl Spawn for EmbassyAdapter deleted. Static generic_task_runner task pool, BoxedFuture, and the unsafe Pin::new_unchecked cast are all gone.
  • embassy-task-pool-8 / -16 / -32 Cargo features deleted. FuturesUnordered grows as needed within a single Embassy task's heap budget.
  • EmbassyAdapter::new_with_spawner deleted. new_with_network(spawner, network)new_with_network(network).
  • unsafe impl Send/Sync for EmbassyAdapter retained when embassy-net-support is enabled (single-threaded cooperative executor makes it sound; Stack has a RefCell).

aimdb-wasm-adapter

  • bindings::poll_sync helper deleted — no callers now that TypedRecord::produce is sync.

Connector crates (aimdb-knx-connector, aimdb-mqtt-connector, aimdb-websocket-connector)

  • ConnectorBuilder::build() now returns Vec<BoxFuture<'static, ()>> instead of Arc<dyn Connector>.
  • spawn_connection_taskbuild_connection_future; outbound command channels are created up front and shared via the captured receiver.
  • R: SpawnR: RuntimeAdapter throughout.
  • No-op transport::Connector impls on *ConnectorImpl types removed (the discarded Arc<dyn Connector> return is gone).
  • KNX: command-channel capacity is now configurable.
  • WebSocket server: start_server()build_server_future() — the axum::serve() accept loop is collected, not spawned.
  • WebSocket client: internal background tasks (write/read loop, keepalive, reconnect watcher) are temporarily bridged to tokio::spawn directly under #[cfg(feature = "std")]. To be moved to nested FuturesUnordered in the AimX portability follow-up.

Remote access / AimX

  • The remote-access supervisor's outer future is collected like any other build-time task; three internal runtime.spawn(...) sites (per-connection handler, per-subscription event stream, subscribe_record_updates) keep bridging to tokio::spawn under #[cfg(feature = "std")] and will be removed by the AimX portability follow-up.

Examples

All example binaries updated to the new let (db, runner) = builder.build()?; runner.run().await pattern: weather-mesh (alpha/beta/gamma), embassy-knx/mqtt-connector-demo, tokio-knx/mqtt-connector-demo, remote-access-demo, hello-single-latest-async, sync-api-demo. Producer/consumer signatures collapse from Producer<LightControl, EmbassyAdapter> to Producer<LightControl>.

Breaking changes — migration checklist

Before After
let db = builder.build()?; let (db, runner) = builder.build()?; then runner.run().await
db.spawn_task(fut) Use on_start(...) at build time, or own FuturesUnordered
impl Spawn for MyAdapter { ... } Delete it; Runtime no longer requires Spawn
producer.produce(x).await? producer.produce(x);
let Ok(reader) = consumer.subscribe() else { ... } let reader = consumer.subscribe();
let p: Producer<T, TokioAdapter> = ... let p: Producer<T> = ...
let c: Consumer<T, TokioAdapter> = ... let c: Consumer<T> = ...
producer.key() Capture the record key at registration
EmbassyAdapter::new_with_network(spawner, network) EmbassyAdapter::new_with_network(network)
EmbassyAdapter::new_with_spawner(spawner) Use EmbassyAdapter::new() or new_with_network(network)
features = ["embassy-task-pool-16"] Delete the feature line
ConnectorBuilder::build() -> Arc<dyn Connector> -> Vec<BoxFuture<'static, ()>>
.tap() on a record with no .buffer(...) (silent → subscribe-time error) Now MissingConfiguration at build

Performance

Producer::produce() steady state: HashMap lookup + bounds check + TypeId check + downcast + buffer push → one virtual call + buffer push. On a 1 kHz source this saves ~1000 lookups/s per producer. The same simplification applies to Consumer::subscribe() (no buffer lookup; the Arc is pre-resolved at build).

Test plan

  • cargo test --workspace --all-features
  • cargo check -p aimdb-embassy-adapter --target thumbv7em-none-eabihf (no_std + alloc)
  • cargo check -p aimdb-wasm-adapter --target wasm32-unknown-unknown
  • Run examples/tokio-knx-connector-demo, examples/tokio-mqtt-connector-demo, examples/remote-access-demo end-to-end
  • Flash examples/embassy-knx-connector-demo and examples/embassy-mqtt-connector-demo and confirm KNX/MQTT round-trips work
  • Run examples/weather-mesh-tokio (alpha/beta/gamma) and confirm DewPoint join transform produces values
  • Confirm examples/sync-api-demo still compiles and runs without .await on produce()
  • Verify cargo check -p aimdb-core --no-default-features --features alloc (no_std + alloc minimum)
  • Confirm runner.run().await blocks until shutdown and that all on_start / connector / transform futures are driven

Out of scope (follow-ups)

  • AimX remote-access: three remaining tokio::spawn sites under #[cfg(feature = "std")] (per-connection handler, per-subscription event stream, subscribe_record_updates) are deferred to the AimX remote-access portability follow-up so this PR stays a focused trait-removal change.
  • WebSocket client's write/read/keepalive/reconnect tasks: same follow-up — they'll move to nested FuturesUnordered.

lxsaah added 8 commits May 23, 2026 16:03
- Updated `WebSocketConnectorImpl` to collect outbound publisher futures instead of spawning them directly.
- Refactored `ServerState` to build the WebSocket Axum server future and return it as a `BoxFuture`.
- Revised design documentation to reflect the removal of the `Spawn` trait and the new approach for handling futures.
- Modified examples to adapt to the new `build()` and `run()` pattern, ensuring proper initialization and concurrent execution of tasks.
- Removed unnecessary `Spawn` trait bounds from various components and updated related code generation.
- Cleaned up example projects to align with the new architecture, including adjustments to how the Embassy adapter is initialized and how the database runner is executed.
…rn `(AimDb, AimDbRunner)` across the workspace
- Updated `Producer<T, R>` to `Producer<T>` and `Consumer<T, R>` to `Consumer<T>`, simplifying user-facing signatures.
- Removed the `.key()` method from `Producer<T>`, as the record key is now captured at registration.
- Adjusted tests and examples to reflect the new type signatures.
- Updated documentation to describe the architectural changes and the motivation behind the removal of `R`.
- Ensured backward compatibility for existing async function signatures while optimizing the hot path for production and consumption of records.
- Changed the `produce` method in various modules from async to synchronous, removing unnecessary await calls.
- Updated related documentation and examples to reflect the new synchronous behavior.
- Ensured that all instances of `producer.produce(value).await` were replaced with `producer.produce(value)`.
- This change simplifies the API and improves performance by eliminating the overhead of async handling where it is not needed.
@lxsaah lxsaah self-assigned this May 25, 2026
@lxsaah lxsaah added the 🏗️ core Core engine work label May 25, 2026
@lxsaah lxsaah linked an issue May 25, 2026 that may be closed by this pull request
9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🏗️ core Core engine work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace Spawn trait with FuturesUnordered

1 participant