-
Notifications
You must be signed in to change notification settings - Fork 0
Tutorial 3 First App
← 2. Scaffold a workspace · Tutorial index · 4. Write a rig (rig.py) →
Now you'll author a real Functional Cluster: model it in .art, generate the C++
skeleton, and fill in one handler. We'll build a tiny counter service — a node
that holds an integer, accepts an Inc (increment, fire-and-forget) and answers a
Get (read, request/reply). It's the smallest thing that exercises both message
shapes.
Worked reference: the in-repo
demo/system/apps/is exactly this, scaled up to four processes. Peek there whenever you want a fuller example.
An app FC is described by two files in system/apps/:
-
package.art— the vocabulary:messages (your wire types),interfaces (typed ports), andnodes (the actors, with their TIPC address, ports, and optionalconfig). -
component.art— the assembly:compositions (group nodes into a process) and acluster(the packaging unit — this becomes your Software Package).
Recall the ladder: node = thread, composition = process, cluster = package.
Open system/apps/package.art (created empty by theia init) and write:
package system.apps
// ---- messages ----------------------------------------------------------
// Increment the counter by `n`. Used with `cast` (fire-and-forget, no reply).
message Inc { int32 n }
// Read the counter. Used with `call` (request → reply).
message Get { }
message GetReply { int32 value }
// Runtime-observable config for the node (persisted via the `per` service).
// Field defaults are metadata: they seed first-boot config + feed migrations.
message CounterConfig {
uint32 step = 1 // how much each Inc adds
string label = "counter" // a human tag
}
// ---- interfaces (typed ports) ------------------------------------------
// senderReceiver: one-way typed messages → drives handle_cast on the receiver.
interface senderReceiver IncIface { data Inc inc }
// clientServer: request/reply → drives handle_call on the server.
interface clientServer CounterSrv {
operation Get(in req: Get) returns GetReply
}
// ---- nodes -------------------------------------------------------------
// One node = one thread = one TIPC address. `atomic` → a GenServer actor.
node atomic CounterNode {
tipc type=0xd0010001 instance=0 // its TIPC address (must be unique!)
config CounterConfig // an etcd-backed, observable config
ports {
server srv provides CounterSrv // answers Get (handle_call)
receiver inc_in requires IncIface // receives Inc (handle_cast)
}
}
A few rules worth internalizing:
-
TIPC
type/instancemust be unique system-wide. The toolchain gates on this (artheia check-addresses) before it will build a manifest — a duplicate address is a silent runtime mis-wire, so it's caught early. -
atomicpicks theGenServerbase (sync-first mailbox:handle_call/handle_cast/handle_info). Other node kinds:runnable(a free worker thread:do_start/do_loop/do_stop) andstatem(aGenStateMfinite state machine). - A field's
= valueis a declared default — it is not part of the wire shape; it seeds first-boot config and feeds schema migrations.
Open system/apps/component.art and assemble the node into a process + a cluster:
package system.apps
// A composition = one process (one executable). Group your node(s) here.
composition CounterProc {
prototype CounterNode counter on process P1
}
// A cluster = the packaging unit — this is your Software Package. Its name is
// the SWP name that ships to the fleet.
cluster Counter {
composition CounterProc p1
}
If your app spans several processes, declare several compositions and add them all to the one cluster — the SWP carries every composition's executable. A multi-process cluster just lists several
composition … <ident>lines.
Before generating any C++, resolve the whole tree — this catches type errors, unwired ports, and address collisions:
artheia parse system/system.art
artheia check-addresses system/system.art # the TIPC-uniqueness gateFix anything it reports. parse returning OK means your .art is internally
consistent.
gen-app projects your .art into a lib/ + main/ + impl/ split under apps/,
and the proto under proto/:
artheia gen-app --kind fc system/apps/component.art \
--out apps --proto-out protoWhat lands where:
apps/CounterProc/
lib/ AUTO-GENERATED, DO NOT EDIT — rewritten verbatim every run
CounterNode.hh the GenServer subclass
apps_codecs.hh, Log.hh
BUILD.bazel
main/ AUTO-GENERATED, DO NOT EDIT — the entrypoint, joins the TipcMux
main.cc
BUILD.bazel
impl/ HAND-OWNED (written once; you edit these)
CounterNode_handlers.cc ← YOUR business logic
CounterNode_state.hh ← the node's state struct
BUILD.bazel
proto/system/apps/ the generated .proto + nanopb .options
The split is the whole point: lib/ and main/ are a pure projection of the
.art — never edit them; to change them, change the .art and regenerate. The
only files you hand-edit are impl/<Node>_handlers.cc and
impl/<Node>_state.hh. gen-app writes those once and refuses to clobber them
(--force overrides, and clobbers both).
Edit the generated files in place — do NOT replace them. gen-app wrote a
complete, compiling skeleton with every method the node's shape requires; you fill
the two bodies that carry your logic and leave the rest as generated. Replacing the
file with just the handlers you care about drops the others and the link fails
(undefined symbol: on_config_update / handle_info).
For CounterNode, gen-app emits five methods (because it has a config block and
the standard mailbox): init, handle_info(const char*), on_config_update(...),
handle_cast(const Inc&), handle_call(const Get&). Leave init, handle_info, and
on_config_update as their generated no-ops; fill only the two below.
First the state — impl/CounterNode_state.hh. gen-app already wrote the struct
inside namespace system_apps; just add the field (don't replace the file —
keep the namespace and the generated includes):
namespace system_apps {
struct CounterNodeState {
int32_t counter = 0; // ← the only line you add
};
} // namespace system_appsThen the logic — in impl/CounterNode_handlers.cc, add #include <string> and fill
the handle_cast / handle_call bodies in place (they live inside the
generated namespace system_apps { … } — leave that wrapper; the other three
methods stay as generated):
// Inc — cast (no reply). Accumulate, honouring the config's `step`.
void CounterNode::handle_cast(const Inc& msg, CounterNodeState& s) {
s.counter += msg.n;
this->log().debug("counter now " + std::to_string(s.counter));
}
// Get — call (request → reply). Answer with the current value.
GetReply CounterNode::handle_call(const Get& /*req*/, CounterNodeState& s) {
GetReply reply{};
reply.value = s.counter;
return reply;
}Note this->log() — every node gets its own tagged logger from the framework mixin.
The handlers run serially on the node's own thread, so the state needs no locking.
Why the extra handlers exist.
handle_info(const char*)is thepost_info()/send_after()tick path;on_config_update(...)fires whenservices/percasts a config change for the node's etcd-backedCounterConfig. A passive node leaves both as no-ops — but they must be defined (theGenServerbase references them), which is why gen-app emits them and you keep them.
bazel build //apps/...//apps/... is your code; it compiles against @pero_theia (the installed
framework) automatically. A clean build means your FC is ready to run.
You can already smoke-test it on your own machine via the bootstrap rig from Chapter 2 — but a real deployment needs a rig that places your cluster onto machines. That's the next chapter.
> **Regen discipline.** Generated files carry an `AUTO-GENERATED … DO NOT EDIT`
> banner. If you change the `.art`, re-run the same `gen-app` command — your
> `impl/` handlers are preserved, `lib/`/`main/` are refreshed. There's a
> regen-stability test that enforces this; never hand-edit generated output.
You now have: a working Functional Cluster — modelled in .art, generated,
with real handler logic, building clean.
Next: Chapter 4 — Write and shape rig.py, where you describe
which machine runs your cluster.
← 2. Scaffold a workspace · Tutorial index · 4. Write a rig (rig.py) →