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:

  1. 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.

  2. It’s serializable.

  3. It allows us to sensibly annotate its type (as opposed to NoEvent = object())