-
-
Notifications
You must be signed in to change notification settings - Fork 4.1k
Event Rearchitecture #20731
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Event Rearchitecture #20731
Conversation
Gotta resolve some conflicts with |
crates/bevy_ecs/src/event/base.rs
Outdated
@@ -93,7 +96,9 @@ use core::{ | |||
label = "invalid `Event`", | |||
note = "consider annotating `{Self}` with `#[derive(Event)]`" | |||
)] | |||
pub trait Event: Send + Sync + 'static { | |||
pub trait Event: Send + Sync + Sized + 'static { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(not related to any specific code, just opening a thread)
I expect existing consumers of "buffered events" to lament this name change, as "event" feels nicer.
As one such consumer, I'll just voice my concerns on the "message" name.
- Collision events are called "events" practically everywhere that supports them. Unity, Unreal Engine, Box2D, Rapier, Avian, Bepu (manually via callbacks), and so on. These tend to be buffered, or sometimes only supported via callbacks/hooks, but I have not seen a term other than "event" for the actual "thing" you are reading. Should we then diverge from the standard naming used everywhere and call them "collision messages"? I'm not sure if I'm comfortable with that.
- Related to the above, Avian currently has a
CollisionEventsEnabled
component. This enables both buffered and observer events for a collider, with the intent that both are fundamentally events, and it is up to the user how they want to consume them, either with a "per-entity" observer API or a buffered API for more performant batched event processing. If we split these concepts so that buffered collision events are no longer "events", this suddenly becomes more awkward, as we need to either split the component intoCollisionEventsEnabled
andCollisionMessagesEnabled
(I can see a lot of people reaching for the former and being confused why they're not getting buffered events), or by renaming to something likeContactReportingEnabled
, which removes the event/message concept name and is a lot more ambiguous. - To me, a "message" in everyday life is way more targeted than an event is. You don't simply send a message into the void and have arbitrary consumers read it; they are more commonly scoped to a specific target, whether that is a DM on Discord, a thread on Reddit, or a response to someone's tweet. Messages are targeted either implicitly or explicitly, so to me the semantics are quite different from what I'd expect for (most) non-observer events.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In support of “message”, this is the term used by Erlang (and Elixir) to describe a data structure that is sent from one actor to another via an asynchronous buffer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To me, a "message" in everyday life is way more targeted than an event is.
This is my opinion too. I don't particularly care though: both events and messages are good names, and as long as they have distinct names I'm happy enough.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my mind, an "event" represents an instantaneous happening, whereas a "message" is something that is stored and forwarded like an email. There's an entire sub-industry built around "message queueing", in which messages are routed and processed in ways that are fundamentally buffered.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because you are treating both behaviors as the "same" currently, I think you can largely dodge this terminology change:
- Continue using the CollisionEventsEnabled component.
- Continue referring to the events produced by Avian as "events".
- Derive both
EntityEvent
andMessage
for your events - Consider it as "we are also sending copies of these Collision Events as Messages" / "this Message is a Collision Event"
Aka I don't think you need to have a separate set of OnCollisionStartEvent / OnCollisionStartMessage types.
To me, a "message" in everyday life is way more targeted than an event is.
This is a reasonable point. Currently buffered EventWriters write their events without knowing who will consume them. However they do write to a specific place (the buffer). Its like someone is writing a message on a public bulletin board (or in a public forum). So perhaps MessageWriter
is a better name than MessageSender
(which more tightly confines the context to a specific set of receivers).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So perhaps
MessageWriter
is a better name thanMessageSender
This would be consistent with queuing systems: one tasks writes to a MessageQueue
, another reads from it.
Fully agree here :) Changes to internals can wait, but I would really like to nail down the fundamental user facing API and terminology. Really glad you're taking a look here. I'm aligned with you on the core principles: less duplication, more staticness, more efficiency! I'll leave more detailed thoughts in review comments to allow them to be collapsed and threaded. |
// SAFETY: Caller ensures `trigger_ptr` is castable to `&mut E::Trigger` | ||
let trigger: &mut E::Trigger<'_> = unsafe { trigger_ptr.deref_mut() }; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This safety comment is not entirely correct. The caller can only guarantee that trigger_ptr
is castable to &mut E::Trigger<'a>
for some lifetime 'a
chosen by the caller to trigger_with
and similar methods. Here however we're casting it to a &mut E::Trigger<'w>
for some arbitrary lifetime 'w
. This is fine only as long as nothing about the lifetime 'w
that 's different from 'a
is ever exposed to safe code, effectively forcing the safe code to be universally quantifed over a set of lifetimes that must include the original 'a
.
Practically speaking, a requirement (but I'm not sure if this is sufficient!) is that On
must never expose anything with the 'w
lifetime except E::Trigger<'w>
(even &'w mut E::Trigger<'w>
or &'w E::Trigger<'w>
would be unsound!) and nothing else must be exposed with the same 'w
lifetime as a On<'w, E, B>
. This is not documented anywhere and seems very easy to accidentally break, especially with how On
is defined.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm isn't this just constraining the lifetime to a smaller scope though? We are pretending that the 'a
in Trigger<'a>
is actually the lifetime of the references to Trigger<'a>
(which we call 'w
). In that context, the lifetime of 'a
must be greater than or equal to 'w
, for any 'w
right?
@@ -0,0 +1,180 @@ | |||
mod collections; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tychedelia I'm moving your comment here to support threaded conversation.
@tychedelia's original comment:
I'm convinced at this point that "buffered events" and "observer events" sharing concept names is wrong. We need two clean and clear terms, and I'm willing to give "buffered events" a slightly worse name if it means "observer events" can be nicer.
Overall I prefer the direction of this PR to the conceptual muddle we were wading into, but I'm not totally sure I understand why sharing concept names is wrong here? Event/message tends to be pretty exchangeable in practice. For example, the Apache Kafka docs note:
An event records the fact that "something happened" in the world or in your business. It is also called record or message in the documentation.
This maybe depends a bit on your background, and I do think that "event" being targeted is intuitive in UI development in a way that message is not. But being targeted is also a property of buffered message queues, for example event sourcing patterns where you are observing a sequence of
OnAdd
,OnChange
,OnRemove
messages targeting certain database rows, etc. Indeed, pull based systems are often used to back more ergonomic push based APIs, see Spring cloud streams, SQS Lambda triggers, etc.So I think the naming here is absolutely correct if they indeed should be separate, but at least to this reader it doesn't exactly resolve the ambiguity for me. Buffering vs pushing still feels like an implementation detail to me.
Maybe the argument just really is that in our game engine specifically, observers have a kind of special status? Whereas messages are more just a general utility you could absolutely re-implement yourself. I think I'm convinced of that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Event/message tends to be pretty exchangeable in practice. For example, the Apache Kafka [...]
I agree that in the industry "message" and "event" are often interchangeable concepts.
So I think the naming here is absolutely correct if they indeed should be separate, but at least to this reader it doesn't exactly resolve the ambiguity for me. Buffering vs pushing still feels like an implementation detail to me.
This is about contracts, teaching, predictability, and expectation management. It is not an implementation detail. Events and Messages differ on many fronts from a user perspective:
- Events are consumed one-by-one in Observers, which exist outside of a schedule. Messages are consumed by iterating over many of them in normal systems, which exist in one or more places inside a schedule.
- Event handlers are run for developers. Message consumers are responsible for dispatching handler logic themselves.
- Events are handled immediately. Messages are handled at some later moment in time (or not at all).
- Events need additional configuration to make them work (
Event::Trigger
). Messages do not. - Sending/Handling Events incurs a degree of overhead. Sending/Handling Messages is about as fast as it can be.
Perhaps most importantly: this is about predictability / UX. Lets say someone is consuming a ProcessingFinished
event from some 3rd party library. If events can either be "buffered" or "observed", the consumer has no way to know how to consume the ProcessingFinished
event. Unless the author was aware of this issue, decided to be careful / documented this, and the consumer knew to read said docs, then the consumer just needs guess. Is their Observer not running because ProcessingFinished
never happened, or because the event was never "triggered" and instead just written to the buffered queue?
That is not a good situation to be in. The hard conceptual line here makes building and consuming APIs much clearer. Developers can unambiguously define "things that happen" in their APIs and developers can know exactly how to consume those things.
Maybe the argument just really is that in our game engine specifically, observers have a kind of special status
Yes, but I wouldn't frame it that way. Observers
(and the Events they run on) are functionally a completely different system with a different API surface, different behaviors, different timing, different performance, etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't mean implementation detail in the casually dismissive sense. Agree that differences are extremely important for consumers. But they can and often do carry the same kind of information, i.e. their differences are in their implementation and not necessarily in their content.
I think this is most obvious with something like RemovedComponents
. On the one hand, the Remove
observer is much easier to work with and having the equivalent (#2148) change detection filter would be quite nice.
But it also makes sense to me that, just like a buffered message queue of removed component ids, we probably could/should also have an AddedComponents
message, etc. Especially in a world where components are more easily introspectible, it totally would make sense to me that you may want to performantly consume a queue of all change events to put into some sink (telemetry, etc). In which case, Remove
should also probably be Message
.
I agree that when looking at the docs, consumers should easily be able to tell how they are supposed to consume an event. I also like the naming of Message
a lot better than two traits both sharing the event nomenclature. But if many things end up being both Event
/Message
I think there's still potential source for confusion. Because they both are events in the vernacular sense.
So ig I'm not really objecting to anything in particular, just noting that I think the venn diagram potentially has a lot more significant overlap that undermines these being cleanly severable concepts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But it also makes sense to me that, just like a buffered message queue of removed component ids, we probably could/should also have an AddedComponents message
Perhaps, although we'd have to weigh this functionality against the performance cost. I think theres a strong argument to remove RemovedComponents.
we probably could/should also have an AddedComponents message
I'm thinking we might want a plugin that adds an observer for an arbitrary event type and pipes it into a MessageWriter. Then we don't need to pay this cost by default.
It might even be worth removing the Message
trait entirely to allow anything to be sent as a message.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The core widgets demo uses RemovedComponents
(as does feathers), and it's pretty ugly - I would love a better alternative.
One potential approach would be some kind of unified message stream for all mutations (add, remove, replace, etc.). Because this would be expensive, it would have to be strictly opt-in, perhaps via a marker component or query filter. This sounds very much like some of the things Sander has done.
This starts to get into the whole discussion around reactivity, which is likely off-topic for this PR. Ideally, you'd want some way to handle all changes to components, assets, and resources in a uniform way, so that you could then have arbitrary reactive functions that are able to depend upon heterogenous inputs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might even be worth removing the Message trait entirely to allow anything to be sent as a message.
This is also where my head goes because it allows observers to own the event concept without having a separate also-kinda-event trait. This is also what I meant by messages not being "special" in the engine like observers are. You could roll your own with no trait bound and whatever storage you want in like a dozen lines of code unlike observers.
I'm thinking we might want a plugin that adds an observer for an arbitrary event type and pipes it into a MessageWriter. Then we don't need to pay this cost by default.
I'm definitely not a fan of RemovedComponents
and it feels like an awkward gap in the API (although the semantics of a Removed
filter are somewhat unclear to me).
But I will note this is kinda how change data capture evolved in SQL. First it was built via database triggers and then most engines moved to reading directly the WAL or some equivalent for performance reasons.
I'd much prefer API consistency above hypothetical performance concerns, though. Until observers are shown to have a serious deficiency for this use case.
Tbh I think you've pretty convinced me of the above. The ability to (optionally) express message producers as observers sounds powerful and flexible.
Note: This is a draft PR. It needs a quality pass and a bunch of doc changes. Don't waste bandwidth by commenting on the doc stuff
There is general consensus that our terminology for Events, "entity events", Observers, and BufferedEvents needs clarity. Additionally, many of us also agree that the current Observer system would benefit from additional static-ness: currently it is assumed that you can use events in pretty much any context, and they all go through the exact same code path.
Alice put forth a proposal to Overhaul Observers, and we have already partially implemented it for 0.17. I think it does a great job of outlining many of the issues at play, and it solves them reasonably well. But I also think the proposed solution isn't yet ideal. Given that it is already partially implemented for 0.17, it is a breaking change, and given that we have already broken the Observer API a number of times, I think we need to sort this out before the next release.
This is a big changeset, but it is largely just a reframing of what is already there. I haven't fundamentally changed the behaviors. I've just refined and constrained in a way that allows us to do what we are currently doing in a clearer, simpler, and more performant way.
First, I'll give some quick notes on Alice's proposal (which you all should read if you haven't yet!):
Notes on Alice's Proposal
app.add_broadcast_observers()
,app.add_universal_observers()
,Observer::entity_observer
,Observer::broadcast
, etc. TheOn
event should statically determine whether an observer is an "entity observer" or a "broadcast" Observer. This would already be encoded in the type system and is therefore something we can do on the developer's behalf. Likewise, any observer being registered at a top level is inherently not a specific entity observer. All of these variants serve to make users guess and poke around in a way that is unnecessary. I want simple one word concept names, single constructors, etc.Proposed Principals
Concepts
world.trigger_ref_with
makes it possible to pass in mutable reference to your own Trigger data, making it possible to customize the input trigger data and read out the final trigger data.Trigger
determines which observers will run.Trigger
that expects a specific kind of event (ex:E: EntityEvent
).EntityEvent
trait, which defines anevent.entity()
accessor. This is used by theTrigger
impls :EntityTrigger
,PropagateEntityTrigger
, andEntityComponentsTrigger
.Message
is a solid metaphor for what this is ... it is data that is written and then at some later point read by someone / something else. I expect existing consumers of "buffered events" to lament this name change, as "event" feels nicer. But having a separate name is within everyone's best interest.The Changes
Event
trait changesEvent::Trigger
, which defines what trigger implementation this event will useTrigger
traittrigger_targets
have been replaced bytrigger
, which can now be used for anyEvent
EntityEvent
trait changesEntityEvent
trait. It now lives on theTrigger
trait (specifically thePropagateEntityTrigger
trait).EntityEvent
now providesentity / entity_mut
accessors for the Event it is implemented forEntityEvent
defaults to having no propagation (uses the simplerEntityTrigger
)#[entity_event(propagate)]
enables the "default" propagation logic (uses ChildOf). The existing#[entity_event(traversal = X)]
has been renamed to#[entity_event(propagate = X)
EntityEvent
requires either a singleMyEvent(Entity)
, theentity
field name (MyEvent { entity: Entity}
), orMyEvent { #[event_entity] custom: Entity }
AnimationEvent
trait, which sets theAnimationEventTrigger
. This allows developers to pass in events that dont include the Entity field (as this is set by the system). The custom trigger also opens the doors to cheaply passing in additional animation system context, accessible throughOn
EntityComponentsTrigger
EntityComponentsTrigger
, which passes in the components as additional state. This significantly cuts down on clones, as it does a borrow rather than cloning the list into each observer execution.entity
field.explode: On<Explode>
notevent: On<Explode>
event.entity()
. This allows us to use more specific names where appropriate, provides better / more contextual docs, and coaches developers to think ofOn<MyEvent>
as the event itself.Take a look at the changes to the examples and the built-in events to see what this looks like in practice.
Downsides
trigger_ref
, and mutate the event on each call to change the target.trigger_target
behavior.EntityTargetTrigger
(not currently provided by bevy, but we could), which brings back the old behavior. This would be used withtrigger_with
to replicate the old pattern:world.trigger_with(MyEvent, [e1, e2].into())
(or we could make theinto()
implicit)trigger_ref
will result in the event'sEntityEvent::entity()
being the final bubbled entity instead of the initial entity.#[event(trigger = AnimationEventTrigger)]
)Draft TODO
Next Steps
BufferedEvent -> Message
rename was not included to keep the size down.Fixes #19648