6. Spot¶
Guide Home | Previous: 5. Channel Messaging — Request · Send · Pub/Sub | Next: 7. Actor and Spot
View in another language — C#/.NET · C++ · Java · Kotlin · Node/TypeScript
The document that owns this chapter's contract — the Spot model and SPOT messaging own the behavior, and the per-language Spot public contract owns the exact signatures. Actor and Spot membership are explained in Actor & Spot Hosting.
A Spot is an execution unit found by a string ID, like a room, stage, or zone. SpotId is
unique across the whole Location Store and is case-sensitive. The application doesn't
choose or hold onto the NodeRid where a Spot lives — the framework looks up its current
location and generation.
1. Three Kinds of Spot¶
All three kinds are Spots that carry an ID and state and run callbacks in order, but they differ in when they're created, Actor membership, and their closing contract.
| Entry Spot | User Spot | Instance Spot | |
|---|---|---|---|
| When it's created | The Framework creates it at Object Server startup | The application creates it explicitly through the spot manager | Created when the first direct message for that ID arrives (cold activation) |
| Spot ID | Issued by the Framework | create — the Framework issues it; get_or_create — the caller specifies it |
The caller specifies it as the message's target ID |
| Stable type | Not registered | Required | Required |
| Actor membership | Supported. The default execution location right after Actor creation | Supported. Actors move in and out via join/leave | Not supported |
| Application close | Not provided | close, or close from a local context |
Close from its own handler/timer context |
| Primary use | The default location for an Actor not yet belonging to a User Spot | Room, stage, zone | An ID-based request-processing unit, like a matchmaking worker |
The lifecycle callbacks each kind receives also differ. The names follow the language, but the call conditions and order are the same.
| Callback | Entry | User | Instance | When |
|---|---|---|---|---|
configure |
O | O | O | The configuration phase where handlers are registered |
on_create |
X | O | X | Confirms a new User Spot creation request and decides whether to accept it. Not called when an existing Spot is found |
on_initialize |
O | O | O | Initializes the created instance. An Instance Spot receives only this, with no on_create |
on_closing |
O | O | O | Before a still-valid local instance is cleaned up (see §4.1 below) |
on_actor_join |
X | O※ | X | Approves or rejects an existing Actor's attempt to join this User Spot |
on_create_actor |
O※ | X | X | Approve/reject a new Actor's initial Entry Spot membership |
OnJoinedActor |
O※ | O※ | X | Notifies the destination Spot once the join commit is done |
on_leave_actor |
O※ | O※ | X | Notifies the origin Spot after commit. Doesn't mean the Actor is gone |
on_disconnect_actor |
O※ | O※ | X | When a connection for an Actor belonging to that Spot drops |
※ Only applies to a Spot that specifies an Actor type and supports Actor membership.
Membership callbacks are split between the Spot the Actor leaves and the Spot it joins. So even
when an Actor that was in a User Spot returns to the Entry Spot, the Entry Spot's
on_create_actor and on_actor_join are not called — returning to the Entry Spot is default
membership, so there's no approval step. In both directions, only the arriving side's
OnJoinedActor and the leaving side's on_leave_actor run after commit.
User Spot and Instance Spot play different roles. For a User Spot, the caller specifies the ID or the Framework issues a new one. Instance Spot doesn't use a separate create API — if you specify the instance type on the first message, the Framework either picks an existing instance or creates one wherever needed, then processes that same message.
1.1 Seeing It in a Real Sample¶
The Bingo sample uses all three kinds. The Play server registers an Entry Spot and a User Spot to hold rooms; the Matchmaking server registers an Instance Spot to hold the matching queue.
// Play server -- an Entry Spot and a User Spot to hold rooms.
mesh.set_object_role (object_role_t::server)
// An Entry Spot has no stable type.
.add_entry_spot<bingo_entry_spot_t> (
[] (entry_spot_context_t c) { return std::make_shared<bingo_entry_spot_t> (std::move (c)); })
.add_spot_factory<bingo_room_t> (
// Stable type -- selected by this name when creating.
sample_names_t::room_spot_type,
[] (spot_context_t c) { return std::make_shared<bingo_room_t> (std::move (c)); },
[] (auto &factory) {
factory.set_execution_mode (user_spot_execution_mode_t::spot_wide);
factory.template preserve_state_with<bingo_room_relocation_adapter_t> ();
});
// Matchmaking server -- an Instance Spot to hold the matching queue.
options.add_route_mesh (sample_names_t::matchmaking_mesh_name)
.set_routing_id (zlink::routing_id_t::from (std::string ("matchmaking")))
.listen (configuration.node.mesh_endpoint)
.set_object_role (object_role_t::server)
.add_instance_spot_factory<bingo_matchmaker_t> (
sample_names_t::matchmaker_spot_type,
[] (instance_spot_context_t c) {
return std::make_shared<bingo_matchmaker_t> (std::move (c));
},
[] (auto &factory) { factory.recreate_on_relocation (); });
The difference shows up on the calling side. An Entry Spot isn't a call target (it's already ready when the server starts), a User Spot has a separate create call, and an Instance Spot has no such call at all. Bingo's single matchmaking handler shows the latter two together.
// Instance Spot -- no create call. Sending to that ID creates it if it's missing.
auto allocated = co_await spot_client
.request_to_spot ("match:" + level_bucket, reserve_bingo_room_req_t{})
// The intent that creation is OK if it's missing.
.instance_spot (sample_names_t::matchmaker_spot_type)
.in_mesh (sample_names_t::matchmaking_mesh_name)
.async<reserve_bingo_room_res_t> ();
// User Spot -- there's a separate create call.
auto created = co_await spots
.get_or_create (allocated.room_id, sample_names_t::room_spot_type)
.in_mesh (sample_names_t::play_mesh_name)
// Delivered to the new Spot's on_create.
.request (allocated.settings)
.async ();
A User Spot or Instance Spot also specifies its relocation policy in factory registration. It can't be omitted, and what to choose is covered by Actor & Spot Hosting.
2. Registering with the Object Server¶
The MeshNode that runs a Spot registers the Object Server role and its factory. Placement
targets aren't chosen with a fixed NodeRid — any Serving node that registered the same
stable type becomes a placement candidate.
auto mesh = options.add_route_mesh ("play");
mesh.listen ("tcp://0.0.0.0:9001").set_routing_id (zlink::routing_id_t::from (std::string ("play")));
mesh.set_object_role (object_role_t::server)
// Registers the Entry Spot an Actor is placed in first.
.add_entry_spot<play_entry_spot_t> (
[] (entry_spot_context_t c) { return std::make_shared<play_entry_spot_t> (std::move (c)); })
.add_spot_factory<game_room_t> (
"game-room",
[] (spot_context_t c) { return std::make_shared<game_room_t> (std::move (c)); },
[] (auto &factory) {
factory.set_execution_mode (user_spot_execution_mode_t::spot_wide);
factory.disable_relocation ();
})
.add_instance_spot_factory<matchmaker_t> (
"matchmaker",
[] (instance_spot_context_t c) { return std::make_shared<matchmaker_t> (std::move (c)); },
[] (auto &factory) { factory.recreate_on_relocation (); });
2.1 The Execution Model — Concurrency Scope¶
Work coming into a Spot waits in one of two queues. Direct packets and timers addressed to the Spot itself go into the Spot queue; payloads addressed to an Actor belonging to that Spot go into the Actor queue. Whether work from different queues can run concurrently is decided by the Spot kind and its execution mode.
| Serialization scope | State ownership | |
|---|---|---|
| Entry Spot | Serializes the Spot queue and each Actor queue separately. Different queues can run concurrently | Each Actor owns its own. Put state shared between Actors in external storage |
User Spot SpotWide (default) |
Serializes the Spot handler, member Actor handlers, timer, and lifecycle callbacks all through one common gate | The Spot instance owns it. No separate synchronization is needed for state shared with Actors either |
User Spot PerActor |
Serializes separately per Actor and per Spot lane. Different lanes can run concurrently | Each Actor owns its own. Put state shared across lanes in external storage |
| Instance Spot | Serializes the Spot queue's direct handlers and timer. There's no Actor queue | The Spot instance owns it |
The default is SpotWide, and most cases use this mode. Because every callback for that
Spot is serialized through one common gate, the Spot instance and its member Actors can
share the same state without any separate synchronization. In relocation too, the Spot and
its member Actors move together as one unit. On the other hand, one slow callback delays
every subsequent callback for that Spot.
Here is how SpotWide handles many requests without locks. Every callback bound for one Spot (including other Actors' messages, timers, and lifecycle) passes through one common gate and runs one turn at a time on a single lane. Because no two turns run at the same moment, the handler mutates Spot and member-Actor state directly with plain code and no lock.
Choose PerActor when you need per-Actor independent execution to get throughput. Treat
the Spot itself as a stateless shell. Because different lanes run concurrently, keep state
that multiple Actors change together, and the Spot-level schedule, in external storage like
Redis or a database with its own synchronization. Only RecreateOnRelocation() is available
as the factory relocation approach. The Entry Spot uses the same model, so it's under
the same constraint.
Serial execution doesn't mean holding a thread the whole time. When a handler reaches an
await, the execution thread can go handle other work, but that turn is held until the
handler completes. Under SpotWide, the next callback for the same Spot doesn't start
during that time. If you need to run the next turn while waiting on slow I/O, use the
Yield contract from Timer And Worker.
The execution mode is fixed at factory registration and doesn't change while running.
Which queue each thing goes into is also fixed. In particular, a business message addressed to an Actor goes straight to the Actor queue, bypassing the Spot queue. It's not a structure where the Spot callback receives the message and hands it over.
| Queue | Goes in | Doesn't go in |
|---|---|---|
| Spot application queue | Payload addressed to the Spot, matched Logical Multicast payload, timer callback, Actor join/leave and lifecycle callbacks | Actor business payload |
| An Instance Spot's queue | Payload addressed to the Spot and timer callback | Everything Actor-related. Rejected at registration time |
| Actor queue | Actor business payload | — |
Trying to register Actor membership or a Logical Multicast subscription on an Instance Spot is rejected at the time of registration or Spot preparation, not while running.
3. Creating a User Spot¶
The two calls have different purposes. create creates a new Spot, and get_or_create
secures a Spot to use under that ID. Choose between them based on what you want to happen
if it already exists.
create |
get_or_create |
|
|---|---|---|
| Purpose | Creates one new Spot | Makes the Spot at that ID usable |
Result State |
Created or Rejected |
Existing / Created / Rejected |
| When it already exists | N/A, since the Framework always issues a new SpotId | Ends as Existing; doesn't run the factory or on_create |
| SpotId | Issued by the Framework | Specified by the caller |
| On failure | No usable Spot | No usable Spot |
create — when creation itself is the business result. Use it where
creation is the point, like opening a new room. The result is one of two: it was created
(Created) or the create callback rejected it (Rejected).
auto created = co_await spots
// Selects the factory and placement candidates by the stable type.
.create ("game-room")
.in_mesh ("play")
// The create request delivered to on_create.
.request (create_game_t{"ranked"})
.timeout (std::chrono::seconds (10))
.async ();
if (created.state == spot_create_state_t::rejected)
throw std::runtime_error ("Game creation was rejected.");
// Use only the global SpotId for messaging from here on.
auto spot_id = created.spot.spot_id ();
See it in a sample — TicTacToe. This is where the API server receives
POST /gamesand creates a room on the Play server. Below is that call taken straight from the actual sample in the repository.
// co_await는 coroutine 안에서만 쓴다. 이 handler의 반환형이 task_t인 이유다.
auto room = co_await _spots.create (sample_names_t::match_spot) // 이 stable type을 등록한 node가 후보가 된다.
.in_mesh (sample_names_t::game_spot_node) // Spot을 만들 mesh를 고른다.
.creation_request ( // 새 Spot의 생성 callback에 전달할 최초 설정이다.
tictactoe_game_create_req_t{
game_name, sample_names_t::required_level})
.async (); // C++의 비동기 완료 terminal이다.
get_or_create — when it's enough that the ID is usable. Use it where you need "use it if
it exists, create it if not." The result distinguishes whether it already existed (Existing)
or was just created (Created), and both give you a SpotRef ready to use right
away. Even if multiple callers request the same ID at once, the Framework runs the create
attempt only once, so the application doesn't have to guard against the race itself.
auto result = co_await spots
.get_or_create ("lobby-eu-1", "lobby")
.in_mesh ("play")
// Not delivered if this ends as existing.
.request (create_lobby_t{"eu"})
.async ();
switch (result.state) {
// Uses the lobby that already existed, as-is.
case spot_create_state_t::existing:
// This call created it.
case spot_create_state_t::created:
break;
// The create callback rejected it -- no Ready Spot.
case spot_create_state_t::rejected:
throw std::runtime_error ("Lobby creation was rejected.");
}
A SpotRef is the exact incarnation at the moment it was looked up. Don't use it for
general messaging -- use it only to close that same incarnation.
auto current = co_await spots.find ("lobby-eu-1");
if (current) {
// Doesn't accidentally close a different generation.
co_await spots.close (current.value ());
}
4. Writing a Spot¶
A Spot handler follows different authoring rules than a channel handler in 05-channel-messaging. Address, lifetime, execution, and state all differ.
| Aspect | Channel handler | Spot handler |
|---|---|---|
| Address | ChannelName -- one of the nodes that can process it |
Spot id -- the one object that owns that state |
| Handler lifetime | Created fresh on every message dispatch | Reuses the same instance for the Spot's whole activation |
| Execution | Different dispatches can run concurrently | Work on the same execution queue runs one at a time |
| Application state | Not kept in a handler field | Owned by the Spot or its member Actor |
A Spot handler is not a method on the Spot class -- it's a separate class bound to that
Spot. It takes the target Spot type as its first generic argument, and receives that Spot
instance as handle's first argument. The Framework creates the handler once per Spot
activation and cleans it up when the Spot closes or relocates. An Actor handler is bound to
its Actor's activation the same way.
4.1 Handler Kinds and the Interface to Implement¶
Which interface to implement depends on what it receives. Whichever it is, it has to match
what was registered in configure().
Each thing received has one matching interface and one registration call.
| What it receives | Matching registration |
|---|---|
| A one-way packet addressed to the Spot | Packet registration |
| A request addressed to the Spot | Packet registration |
| A Logical Multicast subscription event | Subscription registration (specifies channel and topic) |
| A timer tick | Timer registration (specifies name and period, §6.1) |
| A one-way packet addressed to a member Actor | Actor packet registration |
| A request addressed to a member Actor | Actor packet registration |
The interface names and registration methods per language are as follows.
C++ registers a Spot member function instead of a handler class. The registration call itself is the kind.
| What it receives | Registration |
|---|---|
| A one-way packet addressed to the Spot | add_handler<&TSpot::method> () |
| A request addressed to the Spot | add_handler<&TSpot::method> () (the return value is the reply) |
| A Logical Multicast subscription event | add_subscribe<&TSpot::method> (channel_name, topic) |
| A timer tick | add_timer<THandler> (name, period, options) (§6.1) |
| A one-way packet addressed to a member Actor | add_actor_send<&TSpot::method> () |
| A request addressed to a member Actor | add_actor_request<&TSpot::method> () |
A handler takes the target Spot instance as its first argument. It runs inside the Spot, so it touches state directly, with no lock.
See it in a sample — TicTacToe. The handler is where the player in the room makes a move. It handles a request addressed to a member Actor, receiving the Spot and the Actor together. This is actual code from the repository.
// C++은 handler class 대신 Spot member 함수다. 첫 인자가 이 메시지를 받은 Actor다.
inline task_t<place_mark_res_t>
tictactoe_game_spot_t::place_mark (const player_actor_t &actor,
const message_context_t &context,
const place_mark_req_t &request)
{
if (context.packet_name.empty ()) {
throw std::runtime_error ("packet name is required");
}
auto state = match ().place (actor.actor_id, request);
game_state_notify_t state_notify{state};
co_await publisher.publish (state_notify, actor.actor_id);
if (state.status == tictactoe_status_t::won || state.status == tictactoe_status_t::draw) {
co_await publish_win_milestone (actor, state);
}
co_return place_mark_res_t{state};
}
The four branches in their minimal form look like this.
// C++ registers a Spot member function instead of a handler class. The first argument is the target Spot.
class game_room_t : public spot_t<player_actor_t>
{
public:
// A packet addressed to the Spot.
task_t<void> chat (const chat_t &message)
{
// Touches Spot state directly. No lock needed.
append_chat (message.text);
co_return;
}
// A request addressed to the Spot -- the return value is the reply.
room_state_t get_room_state (const get_room_state_t &) { return snapshot (); }
// A subscription event -- arrives on the channel/topic registered with add_subscribe.
task_t<void> score (const score_changed_t &event)
{
apply_score (event);
co_return;
}
// A packet addressed to a member Actor -- receives the Spot and the Actor together.
// The Actor that received this message.
task_t<void> place_mark (player_actor_t &actor,
message_context_t &,
const place_mark_t &message)
{
place (actor.actor_id, message.cell);
co_return;
}
};
An Actor request handler takes the same arguments; the only difference is that its return value is the reply.
Register handlers in configure() and perform initialization and cleanup in lifecycle
callbacks.
class game_room_t : public spot_t<player_actor_t>
{
public:
spot_context_t &context () noexcept override { return _context; }
void configure () override
{
// Registers the Spot send handler.
_context.handlers ().add_handler<&game_room_t::chat> ();
_context.handlers ().add_subscribe<&game_room_t::score> (
// Registers a Logical Multicast subscription.
"game-events", "score.changed");
}
task_t<spot_create_response_t> on_create (const message_t &request) override
{
const auto create = request.decode<create_game_t> ();
co_return (create.mode == "ranked" || create.mode == "casual")
? spot_create_response_t::accept (game_created_t{create.mode})
: spot_create_response_t::reject (invalid_mode_t{create.mode});
}
task_t<void> on_initialize () override
{
// Finishes whatever's needed after creation is approved, before receiving messages.
co_return;
}
task_t<void> on_closing (const spot_closing_context_t &) override
{
// Cleans up application resources by the deadline.
co_return;
}
private:
spot_context_t _context;
};
on_closing's reason distinguishes explicit close, host shutdown, and relocation out. The
Framework cancels the cleanup token when the Deadline runs out.
Not all three reasons come for every Spot kind.
| Close reason | Entry | User | Instance | When |
|---|---|---|---|---|
| Explicit close | X | O | O | When the application starts a close and the local instance is cleaned up normally |
| Host shutdown | O | O | O | When the host cleans up a local Spot with no relocation |
| Relocation out | X | O | O | After committing the owner to the target, when the source instance is cleaned up |
Remember the two cases where it's not called.
- Not called if close fails. If Actor membership is still left on a User Spot and the
explicit close ends in failure,
on_closingdoesn't run. This is why you shouldn't assume "it must have been cleaned up" without checking the close result. - The Entry Spot doesn't close when an Actor leaves. One Actor moving to a different
Entry Spot isn't the Spot instance being terminated, so it doesn't call the Entry Spot's
on_closing.
The Entry Spot itself never relocates. That's why relocation out never happens to an Entry Spot. When a host relocates, what the Framework moves is the Actors belonging to the Entry Spot -- the destination's Entry Spot is already created with a new ID and lifetime when that host starts. So state kept in the Entry Spot doesn't follow the host when it moves -- state that needs to move belongs on the Actor or User Spot.
On host shutdown, the callback runs while Actor membership and the local instance are still alive. Cleanup happens after the callback finishes, so code inside it that reads member Actors is valid.
4.2 The Activation Scope of a Spot and Actor¶
When the Framework activates a Spot, it creates one DI scope and resolves the Spot body's
and Spot handlers' dependencies within that scope. The scope is cleaned up together when the
Spot closes or moves to another node. So a service registered as Scoped is one instance
for as long as that Spot is alive -- unlike being created fresh per HTTP request.
An Actor handler uses a separate Actor activation scope. Different Actors don't share handlers or scoped dependencies. When an Actor leaves/is destroyed or relocates, the source scope is cleaned up and a new one is created at the target.
Even so, injecting an ORM context like DbContext into a Spot's or Spot handler's
constructor causes problems. If one room stays alive for hours, that context lives for hours
too.
| Symptom | Detail |
|---|---|
| Growing memory | The change tracker keeps tracking every entity it queried |
| Stale value reads | Querying the same key again returns the previously tracked instance |
| A stuck error state | If a save failure poisons the context, it never recovers for the rest of the Spot's lifetime |
Registering the handler type as Transient or Singleton doesn't change the lifetime the
Framework sets. The Framework creates the handler and only resolves dependencies within the
activation scope.
Prefer not to access storage directly from the Spot. Ask a service with a channel handler to save/query, and let the Spot own only in-memory state and execution order. A channel handler has a scope per dispatch, so it's fine for it to receive an ORM in its constructor.
// Delegates the save to the handler of the channel responsible for it.
task_t<save_score_reply_t> game_room_t::save_score (const save_score_t &request)
{
auto saved = co_await _context.outbound ()
.request_to_channel ("score",
persist_score_t{_context.spot_id (), request.value})
.async<persist_score_reply_t> ();
co_return save_score_reply_t{saved.version};
}
If you must write directly inside the Spot, open a scope that lives only for that call. Instead of constructor injection, take a scope factory and create and close the scope right where you use it.
Only C++ looks different. A Spot packet/request handler isn't a handler class, it's a Spot member function, and there's no per-call scope surface either. Open and close a short-lived resource directly inside that function.
// C++ has no per-call scope surface. A Spot packet/request handler is a Spot
// member function, not registered as a DI handler class. Open and close a
// short-lived resource directly inside this function.
task_t<save_score_reply_t> game_room_t::save_score (const save_score_t &request)
{
// Closed together when this call ends.
auto session = _store.open_session ();
co_await session.append (_context.spot_id (), request.value);
co_return save_score_reply_t{request.value};
}
A dependency that's fine to hold for the Spot's whole lifetime -- config, a singleton client, a pure computation service -- can be taken via constructor. The test is "is it OK to hold this dependency until the Spot closes."
Where to put state splits by the same rule. Mutable domain state (a room's seats, score, etc.) is owned by the Spot or a member Actor; unchanging configuration goes in a singleton service; infrastructure shared by multiple Spots (cache, counter) goes in a singleton with its own synchronization. A handler field is never the right place to keep state, in any of these cases.
5. Sending a Message to a Spot¶
Regular User Spot messaging needs only the SpotId. The Framework looks up the location and generation from the current authority.
co_await spot_outbound.send_to_spot ("room-42", chat_t{"hello"}).async ();
auto state = co_await spot_client
.request_to_spot ("room-42", get_room_state_t{})
.timeout (std::chrono::seconds (3))
.async<room_state_t> ();
An Instance Spot adds an intent to the same call surface. The argument to
instance_spot(...) is the stable type that chooses which factory to prepare it with. If
that mesh has multiple Instance Spot types registered, it must be specified; if there's only
one, it can be omitted.
// A mesh with multiple types registered -- specify the stable type for which factory creates it.
auto match = co_await spot_client
.request_to_spot ("bronze", find_match_t{player_id})
// Prepares it with this stable type's factory if the target is missing.
.instance_spot ("matchmaker")
// Picks the mesh for initial placement.
.in_mesh ("matchmaking")
.async<match_result_t> ();
// A mesh with only one type registered -- omit it and the Framework picks that sole type.
auto single = co_await spot_client
.request_to_spot ("bronze", find_match_t{player_id})
// Prepares it with the only type registered on the target node.
.instance_spot ()
.in_mesh ("matchmaking")
.async<match_result_t> ();
A call without instance_spot(...) only looks for an already-running Spot and fails without
creating one if it's missing. find doesn't start creation either. In other words, cold
activation only happens when the caller explicitly allows it through intent.
send_to_spot is a one-way operation that only waits for source-local admission. It doesn't
wait for the target handler to complete. request_to_spot waits for a reply or a typed error.
The first message isn't lost even during cold activation. A call with intent attached sends that message together with the activation, and the target durably records it before opening the handler, then restores it to the front of the queue. It's processed as-is as the application payload, without being turned into a separate creation-directive request. The sender doesn't send the same message twice.
On failure, it doesn't auto-resend to a different Spot. Resending under the same ID or a different one after receiving a failure result is a new operation for the application. The previous target may already have run it, so handling duplicate execution is the sender's responsibility.
5.1 Calling a Channel from a Spot Handler¶
A Spot handler or timer can start a channel send/request. That ChannelName doesn't have to exist on the MeshNode that owns the Spot -- as long as one send route with that name is registered anywhere in the same process, it's usable. It can be a route on a different RouteMesh, or a ClientServer client's route.
Route resolution stops at the process boundary. The route isn't resolved through a relay
on another process or MeshNode -- it ends with NotFound. That's why, when deciding which node
to place a Spot on, you also check whether a send route for the channels that Spot calls
is registered in the same process.
6. Timer and Worker¶
Both start from the Spot context but serve different purposes. A timer registers work to run periodically, and a worker runs a slow one-off task outside the Spot queue.
6.1 Timer — Periodic Execution¶
A timer registers a name, period, and handler with the Spot context. The tick goes into that Spot's execution queue, so the handler can access Spot state directly. Registration returns a timer handle, used later to cancel it.
// Inside a Spot -- keep the returned timer_t in a field, used to cancel it later.
timer_options_t options;
options.overrun_policy = timer_overrun_policy_t::skip_late_ticks;
options.max_catch_up_ticks = 1;
options.stop_on_unhandled_exception = false;
// The handler is a separate type from the Spot -- handle (spot, tick) takes two arguments.
_game_tick = _context.add_timer<game_tick_handler_t> (
// A name unique within the same Spot.
"game-tick",
// The period. A configuration error if <= 0.
std::chrono::seconds (1),
options);
// When it's no longer needed. The Framework cleans it up together when the Spot closes.
co_await _game_tick.cancel ();
The handler is a separate class that receives the Spot and tick info.
See it in a sample — TicTacToe. The timer handler that advances the board every second. Taken as-is from the actual code in the repository.
// C++ timer만 별도 handler 타입이다. handle이 대상 Spot과 tick 둘을 받는다.
class tictactoe_game_timer_handler_t
{
public:
task_t<void> handle (tictactoe_game_spot_t &spot, const timer_tick_t &tick) const;
};
In minimal form, it looks like this.
// A C++ timer handler is a separate class. handle takes the target Spot and the tick together.
class game_tick_handler_t
{
public:
task_t<void> handle (game_room_t &spot, const timer_tick_t &tick) const
{
co_await spot.tick_once ();
}
};
The Policy for Handling a Tick Past Its Scheduled Time¶
If work piles up in the Spot queue or a handler runs long, a tick executes later than its
scheduled time. overrun_policy decides how a late tick is handled.
| Value | When it's past the scheduled time | Selection criteria |
|---|---|---|
SkipLateTicks (default) |
Discards late ticks and delivers only the one tick matching the current time | When only the latest state matters -- status broadcast, expiry checks |
CatchUpBounded |
Delivers late ticks up to max_catch_up_ticks and discards the rest |
When the tick count itself matters -- accumulated recovery amount, simulation steps |
DelayNextTick |
Doesn't keep a fixed period; recalculates the next schedule as the previous tick's completion time + period | When a minimum interval between executions must be guaranteed -- polling an external API |
max_catch_up_ticks is only used with CatchUpBounded and defaults to 1. A value at or below
0 is a configuration error at registration time. The first two policies keep a fixed rate
based on the timer's start time, so even if one tick runs late, the next tick's scheduled
time doesn't shift.
The tick value a timer handler receives provides fields for delay against schedule and the number of skipped ticks.
| Field | Meaning |
|---|---|
Name |
The name given at registration |
scheduled_index · delivery_index |
Which scheduled tick this is / the actual delivery sequence number. The difference between the two is how many ticks have been discarded so far |
ScheduledAt · started_at |
Scheduled time / actual execution start time |
ScheduledElapsed · StartedElapsed |
Elapsed since the timer started (scheduled basis / actual basis) |
Delay |
StartedElapsed - ScheduledElapsed -- this tick's delay against schedule |
skipped_ticks |
Number of ticks skipped right before this tick |
Period |
The registered period |
task_t<void> game_tick_handler_t::handle (game_room_t &spot,
const timer_tick_t &tick) const
{
if (tick.delay > std::chrono::milliseconds (500))
// Report load if the delay is large.
spot.report_lag (tick.delay, tick.skipped_ticks);
co_await spot.tick_once ();
}
When a Handler Throws¶
If stop_on_unhandled_exception is left at its default false, only that tick fails and the
timer keeps running. Setting it to true stops that timer -- use it when you need to stop
the same failure from repeating every period. Either way, the failure is recorded in
diagnostics, so check it in logs/trace (see chapter 11. Monitoring §3).
Relocation and Timer¶
When a Spot moves to another node, the Framework moves the timer's name, handler type, period, timer options, schedule cursor, and any tick not yet executed together. So a relocation adapter doesn't need to save the timer or re-register it at the target (§7).
6.2 Worker — Running Long Work Outside the Spot Queue¶
A Spot's execution queue runs only one thing at a time. If you wait directly inside a handler for a heavy computation or external I/O, every other piece of work for that Spot stops for that whole time. Delegate this kind of work to a worker call.
Selection criteria. If the work to delegate is synchronous code that occupies a
thread, use RunCpuWorker; if it's asynchronous code that awaits completion, use
RunIoWorker.
RunCpuWorker |
RunIoWorker |
|
|---|---|---|
| What you pass | A synchronous computation function | An asynchronous call function |
| Where to use it | Work that keeps using the CPU, like serialization, compression, pathfinding, image processing | Work that waits for a response, like a DB, file, or HTTP call |
// CPU worker -- runs synchronous computation on a worker thread.
task_t<snapshot_reply_t> game_room_t::build_snapshot (const build_snapshot_t &)
{
// Copy Spot state first, while still in the turn.
auto board = copy_board ();
auto packed = co_await _context
.run_cpu_worker ([board] (std::stop_token) {
// Heavy synchronous computation.
return snapshot_codec_t::compress (board);
})
.yield ();
co_return snapshot_reply_t{packed};
}
Work that waits on I/O is handed to RunIoWorker.
// I/O worker -- runs an external storage call on a worker.
task_t<save_score_reply_t> game_room_t::save_score (const save_score_t &request)
{
auto version = co_await _context
.run_io_worker ([this, request] (std::stop_token token) {
return _store.save (request.value, token);
})
// The cap on this worker call.
.timeout (std::chrono::seconds (3))
.yield ();
co_return save_score_reply_t{version};
}
There are three terminal operations for receiving the result.
| Terminal | Spot execution rights | Where to use it |
|---|---|---|
Yield(ct) |
Releases them while waiting | The default choice. Other work for the same Spot runs during that time |
Async(ct) |
Holds them while waiting | The work is short and Spot state must not change while waiting |
Submit(ct) |
Returns immediately | When you just want to submit without waiting for the result |
With Yield, other work for the same Spot runs while it's released, so write your code
assuming Spot state may have changed across the Yield. That's why the CPU worker example
above copies the board first. Yield can only be used with a SpotWide User Spot or an
Instance Spot -- an Entry Spot or PerActor has no shared Spot turn, so there are no
execution rights to release in the first place.
The worker thread pool itself (min/max threads, idle time, queue length) is set on the root
options' Worker (chapter 16. Options §2).
7. Signaling When Relocation May Begin¶
Relocation is the procedure that moves a Spot to another node
(03-concepts). The Framework closes
new turn admission at the source, serializes application state through the adapter's
capture, restores it at the target with Restore, then commits authority. The moment
capture is called is called the relocation safe point, and who decides this moment is
chosen at factory registration.
| Mode | Who decides the safe point | Applies to |
|---|---|---|
FrameworkManaged (default) |
The Framework -- between a completed turn and the next | Most Spots |
ApplicationSignaled |
The application -- the end of the turn that called defer() |
A Spot whose state-consistency unit spans multiple turns |
When the default mode works. The Framework doesn't interrupt a running turn. capture
is only called after a handler or tick has finished, so if
state changes complete within a single turn, the state serialized at a turn boundary is
always consistent.
When the default mode doesn't work. If the state-consistency unit
spans multiple turns, the state serialized at a turn boundary can be incomplete. An FPS round
is an example -- a round consists of a start tick, many input packets, and a settlement
tick, and the state in between can't resume the round even if restored. The Framework knows
turn boundaries, but not the consistency unit the application defines. Registering
ApplicationSignaled means the Framework doesn't call capture on its own and instead waits
until the point the application signals.
mesh.set_object_role (object_role_t::server)
.add_spot_factory<game_room_t> (
"game-room",
[] (spot_context_t c) { return std::make_shared<game_room_t> (std::move (c)); },
[] (auto &factory) {
// Only usable in this mode.
factory.set_execution_mode (user_spot_execution_mode_t::spot_wide);
factory.set_relocation_coordination_mode (spot_relocation_coordination_mode_t::application_signaled);
factory.template preserve_state_with<game_room_relocation_adapter_t> ();
});
The application calls defer() in a turn where state is consistent. This call doesn't
perform relocation on the spot. Once the current turn ends, if there's a pending relocation,
the Framework calls capture at that point.
task_t<void> round_tick_handler_t::handle (game_room_t &spot,
const timer_tick_t &) const
{
// Don't signal while a round is still in progress.
if (!spot.try_finish_round ())
co_return;
// The point where the round ended and state was settled. This must be the last Framework call of the turn.
spot.context ().relocation_ready ().defer ();
co_return;
}
What actually happened after signaling comes back through the Spot's
on_relocation_ready_completed. This callback is called in both cases, so put the code that
opens the next round in this one place.
task_t<void> game_room_t::on_relocation_ready_completed (
const spot_relocation_ready_completion_t &completion)
{
// continued -- there was no pending relocation, or it was aborted before commit. Continue on this node.
// relocated -- the move finished, and this callback runs on the new instance at the target node.
start_next_round (completion.outcome == spot_relocation_ready_outcome_t::relocated);
co_return;
}
Follow these rules.
defer()is the last Framework call of that turn. Starting another Framework operation (send, request, close, etc.) in the same turn afterward is an error.- Call it only once per turn. A second
defer()in the same turn is an error. - It's exclusive to a
SpotWideUser Spot. It can't be called from an Entry Spot, aPerActorUser Spot, an Instance Spot, or under the defaultFrameworkManagedmode.
8. Related Documents¶
- A runnable verification example for this chapter's contract: chapter
13. Interface Catalog§3 -- the verification classSpotContracts - Actor creation and Spot relocation: Actor & Spot Hosting
- Session binding: Session Actor Dispatch
- Location Store configuration: Location