Working with objects¶
The examples on this page come from the shipped example packages, calculators, lifecycle and
fibonacci. Where a snippet is written out rather than transcluded, it uses my_package.MyClass
from Create your first package.
Implementing your objects¶
Initial values¶
A property staged in your constructor with setNext...() is published by the first commit, which
happens before the first cycle begins. That makes the constructor a place to set initial values in
code, as an alternative to setting them in the YAML configuration.
Registration¶
If you need to do some work after construction, but just before your object starts to get called,
you can overwrite the registered() function. For example:
void registered(sen::kernel::RegistrationApi& api) override
{
// do something once (and maybe use the api)
}
The registration API lets you obtain sources and register for discovering other objects that you might be interested in.
registered() runs when your object is added to a bus. Everything the kernel builds from a
configuration file is added, so for those objects it always runs, and whatever you acquire there is
in place for every later call, including the destructor. An object you create in code and never add
to a bus is never registered, and anything registered() would have given it stays empty.
Apart from this, you can also implement a function that will get called when the object gets unregistered:
void unregistered(sen::kernel::RegistrationApi& api) override
{
// do something once (and maybe use the api)
}
Updates¶
If your object is published to a bus, your update() is called once per cycle, after
drainInputs(). This allows you to perform periodic updates to your internal state and
trigger your internal logic. For example:
void MyClassImpl::update(sen::kernel::RunApi& runApi)
{
// change prop2 with some dummy values
StructOfInts val = getProp2();
val.field1 += 1;
val.field2 += 2;
setNextProp2(val);
}
The sen::kernel::RunApi, as the name suggests, is the runtime API that
allows your component to interact with Sen. It only has a few (but powerful) methods.
Advanced callbacks¶
If you need finer control over what happens during the drain-update-commit cycle, you can run code just before the drain and just before the commit.
needsPreDrainOrPreCommit() returns false by default, and the two hooks are only called when
it returns true. Overriding the hooks alone does nothing at all, silently, which is the easiest
mistake to make here.
The code below is from the lifecycle example package, which builds as part of the examples. Its
interface is small: it counts the phases and publishes what it saw:
package lifecycle;
// Records which phases of the execution cycle ran on this object, so the
// ordering the kernel guarantees can be observed rather than described.
class PhaseRecorder
{
// Cycles completed since this object was registered.
var cycles : i32;
// How the phases ran in the last completed cycle, staged from preCommit().
// A string is unbounded, and Sen refuses to carry an unbounded property over a
// best-effort transport, so this one has to be confirmed.
var lastCycle : string [confirmed];
// The same summary on demand, without waiting for the next commit.
fn report() -> string [const];
}
/// Opt in to preDrain() and preCommit(). Without this they never run.
[[nodiscard]] bool needsPreDrainOrPreCommit() const noexcept override { return true; }
void registered(sen::kernel::RegistrationApi& api) override;
void unregistered(sen::kernel::RegistrationApi& api) override;
/// Runs before the component drains its inputs, so the object sees the world
/// as it was at the end of the previous cycle.
void preDrain() override;
void update(sen::kernel::RunApi& runApi) override;
/// Runs immediately before the commit, which makes it the last point at which
/// a property staged with setNext...() still lands in this cycle.
void preCommit() override;
preCommit() is the more useful of the two, because it is the last point at which a property staged
with setNext...() still reaches the commit at the end of that same cycle. That lets an object
publish something derived from everything that happened during the cycle:
void PhaseRecorderImpl::preCommit()
{
++preCommits_;
// Staging here still reaches the commit that follows, which is what makes
// preCommit() useful: the object can publish something derived from
// everything that happened during the cycle.
setNextCycles(getCycles() + 1);
setNextLastCycle(reportImpl());
}
Running that example shows the ordering. The first commit happens before any drain or update,
because the kernel commits the initial state of your objects before the first cycle begins. So
preCommit() is called once more than the other two.
cycles=1 lastCycle="preDrain=0 update=0 preCommit=1"
cycles=2 lastCycle="preDrain=1 update=1 preCommit=2"
Object naming convention¶
Sen supports all special characters in published object names, with the single exception of literal
space characters (" "), which are restricted.
Interacting with objects¶
Calling methods¶
Sen is fully asynchronous, which means that calls to methods do not block. This is easy when your method does not return any value, but if it does (and you are interested in it), then you need to provide a callback. For example:
void useCalculatorImpl() override
{
if (const auto& list = calculators_->list.getObjects(); !list.empty()) // (4)!
{
auto handleResult = [](sen::MethodResult<float32_t> result) // (6)!
{
if (result.isOk())
{
std::cout << "add(3, 4) = " << std::to_string(result.getValue()) << std::endl;
}
else
{
std::cout << "add failed" << std::endl;
}
};
// Call add(3, 4) and handle the result
list.front()->add(3.0, 4.0, {this, handleResult}); // (5)!
}
- Subscriptions are set up in
registered(), earlier in the same file. - The subscription is held in a member so that it outlives the call.
- It matches every object implementing the generated interface.
- Always guard against an empty list. Objects can disappear between cycles.
list.front()gives a reference to the first match, valid for this whole update cycle because it was frozen during drain.- The callback receives a
MethodResult, holding either the value or an exception.float32_tis whatf32becomes in the generated header, so the types match exactly. It fires during the drain stage of a later cycle, once the calculator has committed the result.
That is the whole of a real client: find a calculator, call add on it, and handle the answer when
it arrives. The {this, handleResult} pair is the callback. this is what ties the callback to
your object's lifetime, so it stops being invoked if your object goes away.
Handling responses and errors¶
Methods may or may not return values. If you want to obtain the returned value, you can use the
result argument. It's a MethodResult<T> from which you can extract the value (using
getValue()) or the error (using getError()).
Note that if methods do not return any value, you can still register a callback and react to the fact that the method execution is finished (or detect any errors).
if (result.isOk())
{
// it worked, do something with the return value
std::cout << result.getValue() << std::endl;
}
else
{
// Something went wrong - try to find out what.
// The error is an `std::exception_ptr` that we can re-throw
// when interested in knowing more about it.
try
{
std::rethrow_exception(result.getError());
}
catch(const std::exception& err)
{
// do something with err
std::cout << err.what() << std::endl;
}
}
Choose what you catch. std::exception covers everything a call can deliver; a narrower type covers
only what you name, and what it does not match escapes the callback. Within one process the error
arrives as the type the method threw. Across a process boundary that type cannot travel, so what
reaches you is a std::logic_error, a std::invalid_argument, a std::runtime_error or a bare
std::exception. Threading and object
lifetime has the mapping.
If you don't install any callback, you will be effectively ignoring the result of the method call. This includes any potential errors signaled by the method.
Deferred methods¶
A method normally returns its value, and Sen delivers it to the caller. Sometimes you cannot answer
straight away: the work is slow, or it has to be handed to someone else. Marking a method deferred
changes the generated implementation side: you receive a std::promise and fulfill it whenever you
are ready. Callers see no difference.
A method can be marked deferred directly in the STL, with the [deferred] attribute:
It can also be marked in the code generation settings, which keeps the STL free of implementation
choices, so the same interface can be implemented with or without deferral. That is what the fibonacci example
does, so it is the one shown here. Its STL declares an ordinary method:
package fibonacci;
// Example class that does some work. In this case, we
// illustrate it by computing the fibonacci sequence.
class Worker
{
// Returns the Fibonacci number of the element "n" of the Fibonacci sequence.
//
// @param n index of the Fibonacci sequence.
fn computeFibonacci(n: u32) -> u64;
}
// A special kind of worker that forwards the
// incoming work to other workers.
class Manager : extends Worker
{
// Where to look for the workers.
var workersBus : string [static];
}
and the settings file names the ones to defer:
which the package passes with CODEGEN_SETTINGS. See
CMake integration. See also
Customizing the generated code, which covers
the other settings this file can carry.
The generated declaration is then computeFibonacciImpl, taking the promise by rvalue reference:
Note the name. Callers still use computeFibonacci(); the generated base turns that into a
computeFibonacciImpl() on your class, and that is the one you implement.
The fibonacci example ships two implementations. The first does the work and fulfills the promise
when it is done:
class FibonacciWorker: public WorkerBase
{
public:
SEN_NOCOPY_NOMOVE(FibonacciWorker)
public:
using WorkerBase::WorkerBase;
~FibonacciWorker() override = default;
public:
void computeFibonacciImpl(uint32_t n, std::promise<uint64_t>&& promise) override
{
std::this_thread::sleep_for(std::chrono::seconds(5)); // simulate heavy workload
promise.set_value(::fibonacci::computeFibonacci(n));
}
};
The second hands the call to another object and passes the promise into the callback, so whoever answers fulfills the original caller's promise:
/// Forwards the call to a random worker, or does the work if none is found.
void computeFibonacciImpl(uint32_t n, std::promise<uint64_t>&& promise) override
{
if (workers_->list.getObjects().empty())
{
std::cout << "No workers available, manager is performing the task" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(5)); // simulate heavy workload
promise.set_value(::fibonacci::computeFibonacci(n));
}
else
{
auto& selectedWorker = selectRandomWorker();
std::cout << "Sending task to " << selectedWorker.asObject().getName() << std::endl;
selectedWorker.computeFibonacci(
n, {this, [p = std::move(promise)](const auto& result) mutable { p.set_value(result.getValue()); }});
}
}
The lambda must be mutable: it owns the promise, and set_value() is not const. Note also that
a promise cannot be copied, so it cannot be stored in a std::function, which requires a
copy-constructible target. Sen's own callback type accepts move-only targets, which is why passing
the promise into the callback above works.
Reacting to property changes¶
All Sen objects allow hooks that you can use to react to property changes. The generated function
will be named as onXChanged(callback), where X would be the name of the property.
For example:
auto cb = [myObject]() { std::cout << "position changed: " << myObject->getPosition() << "\n"; };
myObject->onPositionChanged({this, std::move(cb)}).keep();
The lambda captures myObject by value. A kept connection outlives the scope that created it, so a
reference capture would dangle. The this in {this, std::move(cb)} does a different job. It names
the object that owns the registration, and the lambda never reads it.
These methods register the callback and return a ConnectionGuard that owns the registration.
NOTE: The call to .keep() keeps the connection established, even after the ConnectionGuard
object was destroyed. What ends it then is the object named in the token, the this above: while
that object is alive the callback keeps being invoked, and once it is destroyed the callback stops.
Threading and object lifetime covers what that guarantees.
If you do not have the generated code, and find yourself working with generic proxies, you can use
the onPropertyChangedUntyped method, which is also available in all objects, but works with
Variants.
Reacting to events¶
In the same way as with properties, you can register as interested in an event by using the corresponding generated function.
auto cb = [](int32_t arg) { std::cout << "something happened " << arg << "\n"; };
myObject->onSomethingHappened({this, std::move(cb)}).keep();
In this case, the callback is expected to receive the same arguments as the event generates.
As with properties, there's a Variant-based option in case you don't have the generated code:
onEventUntyped().
Runtime API¶
Some of what you need comes from outside your own objects: from the kernel, and from other components.
The kernel can give you the following information:
- Your application name via
KernelApi::getAppName(). - The known types via
KernelApi::getTypes()(You won't normally need to use this, as it is aimed at tooling). - The network addresses and ports the process has bound, via
KernelApi::getRuntimeNetworkFootprint(). See the runtime network footprint. - The configuration passed by the user via
ConfigGetter::getConfig(). - Whether you are required to stop, via
RunApi::stopRequested(). - The current (virtualized) time via
RunApi::getTime(). - The time the component's objects started from, via
RunApi::getStartTime(). - The configured cycle time via
RunApi::getTargetCycleTime(), which is set onceexecLoopis running and empty for a component that drives its own loop.
The execution model explains what that time is, how it moves in each run mode and how a model uses it.
You can also ask the kernel:
- To stop, via
KernelApi::requestKernelStop(int exitCode). - To process incoming information, via
RunApi::drainInputs(). - To flush outgoing information, via
RunApi::commit(). - To give you pointers to objects published by other components, and to let you publish your own objects.
The last point is the most interesting for this section because it is where the largest part of the communication happens.
In the Sen API, Object Sources are entities that you can use to publish your own objects and be
informed about other objects. This is implemented in the sen::ObjectSource class.
You can see that it has the methods add(Object) and remove(Object), and it inherits from
sen::ObjectFilter, which contains the addSubscriber and removeSubscriber methods, that you can
use to discover objects based on some criteria that you can define.
Object Sources can be created by calling the method KernelApi::getSource(). This function takes
a string as an argument, and it expects it to be in the format <session>.<bus>. For example, the
kernel always publishes some objects in the "local.kernel" bus.
Finally, you need somewhere to put those discovered objects. The sen::ObjectList class
serves this purpose.
Subscribe to the generated <Class>Interface type, never to an implementation class. Sen generates
an interface for every class you declare in STL, and that is what other objects hold: a remote
object has no implementation on your side to name. The generated code covers
the pair.
Discovering objects on a bus then looks like this:
void registered(sen::kernel::RegistrationApi& api) override // (1)!
{
calculators_ = api.selectAllFrom<CalculatorInterface>(getCalcBus()); // (3)!
}
registered()runs once, after the object joins the bus: the point at which the API is available and subscriptions can be set up.- The subscription is kept in a member variable so it outlives this call. If it went out of scope the list would be destroyed and the callbacks would stop.
selectAllFrom<CalculatorInterface>(getCalcBus())matches every object implementing the generated interface, which is why you subscribe toCalculatorInterfaceand not to an implementation class.
The member it assigns to is declared as
std::shared_ptr<sen::Subscription<CalculatorInterface>> calculators_;, and reading it is
calculators_->list.getObjects(). Tutorial 2 shows the same file
in full.
selectAllFrom also takes an optional callback, invoked during drain with the objects that have
just appeared, which is useful when you want to react to a discovery instead of polling the list.
NOTE: Keep in mind the subscription and the registered callbacks are tied to the lifetime of the
subscription object, so when you want to keep a subscription alive you have to store it for the
required time. For example, when the subscription should be alive as long as your component is
running, you can store it as a member of your component and initialize it in the
registered() function of your object.
This calculators_->list container is a sen::ObjectList<T> which acts like an enhanced
std::vector<T> where T can be the specific type of the objects that you are interested in.
The same discovery is available one level down, where you build the interest yourself and own the
container. selectAllFrom is this with the lifetime taken care of for you, so reach for the lower
level when you need to build the query at run time, or when you want to own the list.
const auto bus = api.getSource("se.env");
bus->add(obj_);
std::ignore = enumSub_.list.onAdded([this](const auto& /*iterators*/) { matchCount_++; });
const auto interest = sen::Interest::make(
R"(SELECT query_test.QueryTestClass FROM se.env WHERE currentStatus = "error")", api.getTypes());
enumSub_.attachTo(bus, interest, true);
The list it subscribes is declared as sen::ObjectList<query_test::QueryTestClassInterface>
objectsInError_;, a member. addSubscriber keeps the address of your container rather than a copy
of it, so the container has to outlive the subscription. That is the rule stated above, with nothing
holding the list on your behalf. The source can stay a local, as it is here: the kernel owns the
bus, and you only need to keep the handle if you use it again later, as the school example does to
remove its objects.
onAdded on the list is the same idea as the callback selectAllFrom takes, and the query shows a
WHERE clause narrowing the interest to objects whose property has a particular value.