Add observer ordering via ObserverSet and topo-sorted dispatch#24328
Add observer ordering via ObserverSet and topo-sorted dispatch#24328caniko wants to merge 18 commits into
Conversation
Foundation for observer ordering (bevyengine#14890). No behaviour change yet — descriptor edges are unread by storage and dispatch; that wiring lands in later commits.
All four dispatch sites in event/trigger.rs now route through a single ordered dispatch helper over sorted NodeId streams. Archetype-flag fast skip is preserved. Cross-bucket ordering is now observable at runtime when observer ordering edges exist.
|
Welcome, new contributor! Please make sure you've read our contributing guide, as well as our policy regarding AI usage, and we look forward to reviewing your pull request shortly ✨ |
|
It looks like your PR has been selected for a highlight in the next release blog post, but you didn't provide a release note. Please review the instructions for writing release notes, then expand or revise the content in the release notes directory to showcase your changes. |
|
run_if already works now :) Add a test for the interaction? |
|
Added follow-up commits completing the public API surface to match SystemSet's shape:
Storage and dispatch are unchanged from the previous commits; this is additive on the builder and diagnostics layer. The new observer tests cover tuple chaining, tuple-wide modifiers, set chaining, naming, and filtered dispatch-order accessors. I also pushed a small follow-up formatting commit after CI caught stable clippy's |
|
The Component-targeted observers still dispatch as separate ordered passes, which can violate the event-local cross-bucket ordering this PR is meant to provide: |
|
Will test this in my game, and see if I find any further bugs before marking the PR as ready. Feel free to try out the PR in the meantime! Thanks all! |
|
Follow-up cleanups force-pushed (commits re-signed, audit-fix message reworded):
The review-feedback commits (set ordering in the fast path, merged component dispatch) were also re-signed in the same rebase. SHAs in the PR description are now up to date. |
729ff41 to
987f280
Compare
API completion + diagnostics: - accept IntoObserverConfigs in observe()/add_observer() sugar across EntityWorldMut, EntityCommands, free entity_command::observe, World::add_observer, and Commands::add_observer; entity-side sites thread watch_entity + chain through configs.observers - replace the stale "not yet wired into dispatch" docstring on ObserverSet with an accurate description of the live API - include observer names alongside entities in the cycle warning Performance: - defer per-cache resort() during batch registration via counter-based bump_defer_resort / release_defer_resort on Observers (propagated to all caches; newly-created caches inherit the count); wrap World::add_observers and World::configure_observer_sets so the topological sort runs once per call instead of once per inner register_observer; panic-safe via catch_unwind + resume_unwind Ergonomics: - debug-assert on watch_entity / watch_entities called after the descriptor has been frozen by registration; release builds keep the silent no-op contract - chain() + in_set() edges are verified to deduplicate in the topo graph via a regression test, relying on DiGraph::add_edge natively deduplicating identical (from, to) pairs Internal: - extract a shared build_ordering_graph helper used by both rebuild_order and the test-only ordering-graph view
987f280 to
036c125
Compare
|
Re-pushed with a rustfmt fold-in and a no_std fix.
Updated SHAs:
Review-feedback commits ( |
|
Added the ordering × Also added the missing release-notes entry the bot flagged ( |
Adds the test alice-i-cecile asked for: an ordered chain across three observer sets where the middle observer carries a `.run_if(false)` condition. Verifies the conditional observer is skipped without breaking the order of its neighbours, and re-runs with the flag flipped to confirm full chain dispatch. Also adds the observer-ordering release note that the release-content bot flagged as missing.
62a485c to
814294c
Compare
Add observer ordering via
ObserverSetand topo-sorted dispatchResolves #14890.
What this changes
Observers can be ordered against each other using a
SystemSet-style API:Cross-bucket ordering works natively: a global observer can be ordered against a per-entity or per-component observer. The implementation uses one topo sort per event key, then reuses the sorted order across all dispatch sites. The existing archetype-flag fast skip is preserved.
Approach
ObserverSettrait + derive, mirroringSystemSet. Identities are stored asInterned<dyn ObserverSet>.CachedObserversreplaces the oldEntityHashMapbuckets with one node table, one topo-sortedorder, and sorted inverted indices per bucket.bevy_ecs::schedule::graph::DiGraph, so observers and systems share the same ordering kernel.event/trigger.rsgoes through onerun_orderedhelper that performs a k-way merge over sortedNodeIdstreams. No per-dispatch allocation.Breaking changes
ObserverMaptype alias removed. Replaced by indexed-Vec storage. Downstream code readingworld.observers()to enumerate observers needs to use a new iterator API. There are very few such users in the ecosystem (mostly debug tools).CachedObservers/CachedComponentObserversfield shapes change. The fields were already private; only the accessors (global_observers(), etc.) were public. The new accessors return sorted slices ofNodeIdplus anodes()table.ObserverSet. Strictly stronger than today's contract; nothing correct can break, but anything that relied on undefined order (test asserts, frame replays) will need updates. Call out in the PR.event/trigger.rschange shape. Internal, noTriggertrait signature changes, but customTriggerimpls in the wild (Bevy picking is the largest) need a quick port to the newrun_orderedhelper. Bevy maintainers can drive this in the same PR.What's NOT in this PR
SystemParamaccess analysis per observer to build a conflict graph.Traversal; per-hop dispatch now uses the sorted observer order.ambiguous_withfor observers. Only meaningful once parallel observer execution exists.Design Notes
Design summary
The key design choice is one event-local observer graph, not four separately sorted buckets. This enables edges across dispatch buckets while avoiding four topo sorts per event. Each registered observer/event pair becomes an
ObserverNodewith a stable insertion sort key.CachedObserversstores a dense node table, an event-wide sortedorder, and bucket indices containingNodeIds sorted by that event-wide order.Dispatch sites pass the relevant sorted streams into
run_ordered. For one stream, dispatch is a direct dense slice walk. For multiple streams without ordering edges, dispatch preserves existing bucket order without allocation. If ordering edges exist,run_orderedwalks the event-wide order and runs nodes present in the active streams, which gives cross-bucket ordering without allocating a merged list.Archetype flags remain the fast skip for lifecycle component observers. Register/unregister returns the same component-observer deltas needed to update those flags, now backed by the new component bucket indices.
Test coverage
New and updated
bevy_ecsobserver coverage includes:ObserverSetderive and set membership.dispatch_order_foraccessor coverage.Discardlifecycle terminology.Local validation
cargo check --workspace: passed.cargo test -p bevy_ecs --doc: passed.cargo clippy -p bevy_ecs -- -D warnings: passed.cargo test -p bevy_ecs: observer tests pass, but the full suite is blocked locally byerror::bevy_error::tests::filtered_backtrace_test. I verified the same test fails on untouched upstreammain(370be1b02) under Rust 1.95.0 with the same assertion, so this is not caused by this branch.Review feedback addressed
run_orderedsingle-stream fast path so observer-set ordering still applies when the dispatch degenerates to one active stream..run_ifon a middle observer (per @alice-i-cecile's ask).Follow-up cleanups
Two additional commits round out the API surface and tighten a few internals:
c4588791f — observer API completion + performance + ergonomics:
observe()onEntityWorldMut/EntityCommands/entity_command::observe, andadd_observer()onWorld/Commands, now acceptimpl IntoObserverConfigs<M>so the same builder chain (.in_set(...),.before(...),.after(...),.chain()) works through every entry point. Entity-side sites attachwatch_entityto every observer in the configs before spawning.ObserverSetdocstring that still claimed observer set configuration was "not yet wired into dispatch".Observer::with_name(...).World::add_observersandWorld::configure_observer_setsdefer per-cacheresort()calls for the duration of the batch via a counter-based deferral onObservers, so a plugin that registers N observers runs one topological sort instead of N. Panic-safe via aDropguard (no_std-clean).Observer::watch_entity/watch_entitiesdebug-assert when called after the descriptor has been frozen by registration; release builds preserve the documented silent no-op contract for back-compat.(a, b, c).chain().in_set(MySet)produces deduplicated edges in the topological graph (relies onDiGraph::add_edgenaturally deduplicating identical(from, to)pairs).build_ordering_graphhelper sorebuild_orderand the test-only graph view use the same implementation.036c12513 —
resolve_set_targetuses aHashSetfor visited tracking instead ofVec+ linearcontains(), and thedispatch_order_for_*family carries a doc note that it allocates per call and is intended for diagnostics, not hot paths.