Skip to content

Tutorial 3 First App

ibnHatab edited this page Jul 4, 2026 · 1 revision

2. Scaffold a workspace · Tutorial index · 4. Write a rig (rig.py)


Chapter 3 — Write your first application

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.

3.1 The shape of an app in .art

An app FC is described by two files in system/apps/:

  • package.art — the vocabulary: messages (your wire types), interfaces (typed ports), and nodes (the actors, with their TIPC address, ports, and optional config).
  • component.art — the assembly: compositions (group nodes into a process) and a cluster (the packaging unit — this becomes your Software Package).

Recall the ladder: node = thread, composition = process, cluster = package.

3.2 Author package.art

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/instance must 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.
  • atomic picks the GenServer base (sync-first mailbox: handle_call / handle_cast / handle_info). Other node kinds: runnable (a free worker thread: do_start/do_loop/do_stop) and statem (a GenStateM finite state machine).
  • A field's = value is a declared default — it is not part of the wire shape; it seeds first-boot config and feeds schema migrations.

3.3 Author component.art

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.

3.4 Validate the model

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 gate

Fix anything it reports. parse returning OK means your .art is internally consistent.

3.5 Generate the C++ skeleton

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 proto

What 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).

3.6 Fill in the handlers

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_apps

Then 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 the post_info()/send_after() tick path; on_config_update(...) fires when services/per casts a config change for the node's etcd-backed CounterConfig. A passive node leaves both as no-ops — but they must be defined (the GenServer base references them), which is why gen-app emits them and you keep them.

3.7 Build it

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.

3.8 Run it locally (optional)

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)

Clone this wiki locally