Events¶
An Event is anything that gets returned from Node,
wrapped in a container with some metadata.
The metadata includes the event’s ID, when it happened, the name of the signal that emitted it, and which node emitted it. It is managed by
EventStore TubeRunner.
Events can be accessed directly by code outside the runner with the add_callback() method,
like the following:
from noob import Tube, SynchronousRunner
tube = Tube.from_specification("...")
runner = SynchronousRunner(tube=tube)
def my_cb(): ...
runner.add_callback(my_cb)
EventStore¶
A runner creates an EventStore that … stores events.
When you’re running a SynchronousRunner, the
EventStore is owned by the runner who manages all nodes,
so this includes every event that gets emitted by every node until they are cleared.
On the other hand, ZMQRunner does not manage a global EventStore. Rather,
each NodeRunner manages events that are relevant to the single node it manages
(ref: ZMQ Runner). Either way, the functionality remains the same:
preserving relevant events while there remains a node that may depend on it,
and collecting the values from within the events and returning them to the runner
so that a relevant node can use them.
MetaEvent¶
Remember earlier when we said Events are things that get returned from nodes? We lied. Sort of. Tee hee.
We felt justifying in lying, because the other types of events are the “internal” events do not get exposed to the user.
We call these events MetaEvent. These work by swapping the signal entry of the event with
MetaEventType, which includes NodeReady and EpochEnded.
Their meanings are quite self-explanatory, where NodeReady signals that a node’s dependencies have been satisfied
and the node is ready to process, and EpochEnded means… well, the given epoch (a full cycle of graph) has completed.
For debugging purposes, we do allow users to access some of these through runner callback. This behavior may change in
the future, however, so we do not recommend you depend on it.
Optional Events¶
Sometimes, a node may decide not to emit anything. It accepted its inputs, processed, and nothing came of it.
Some examples of this include a Gather node that is has not gathered enough to emit its
collection, or a Return node.
Of course, a user-written node may also display this behavior. MetaSignal comes in handy
in these circumstances.
Since we are under the assumption that a node can return quite literally anything,
we cannot flag this NoEvent behavior with anything that a user may use.
For example, if we decide to accept None as a signal for “nothing came out of the node,” we end up reserving the
meaning for None and the user can no longer use None as a semantically meaningful output.
Let’s assume the user wants to use the following function as a node.:
from typing import TypeVar
T = TypeVar("T")
def maybe_first_element(things: list[T]) -> T:
try:
return things[0] # what if the 0th element is None, like [None, 1, 2, ...] ??
except IndexError:
# I don't wanna fail. If things is empty, just skip.
return None # WRONG
Since we already decided to designate None as a flag to mean nothing came out of a node,
it became impossible for the try block to emit a meaningful None.
In this case, it becomes impossible for first_element to distinguish
“hey, the first element in this list is None.” from “hey, there was nothing in a.”
To circumvent this issue, we implemented a singleton NoEvent object. It can be used like below:
from typing import TypeVar
from noob.event import MetaSignal
T = TypeVar("T")
def maybe_first_element(things: list[T]) -> T | MetaSignal:
try:
return things[0] # it's ok if the 0th element is None!
except IndexError:
# If things is empty, just skip with NoEvent.
return MetaSignal.NoEvent
This MetaSignal enum class provides us with a few critical benefits:
It’s a singleton, which means it cannot be confused with anything else. Even if a user made an identical StrEnum object in their own module, there will be no confusion.
It’s serializable.
It allows us to sensibly annotate its type (as opposed to
NoEvent = object())