Actor Synchronization
Multi-actor coordination using SyncUtils.SyncRequestsCheck and readiness-based synchronization.
Overview
ControlBee provides SyncUtils.SyncRequestsCheck for coordinating multi-actor operations where multiple actors must reach a ready state before any proceed. This implements a multi-party rendezvous pattern where a central orchestrator (typically the Syncer) polls readiness statuses and sends "go" messages when all conditions are met.
The SyncRequestsCheck Pattern
Core Concept
┌─────────┐
│ Syncer │ Central orchestrator
└────┬────┘
│ Continuously polls readiness via SyncRequestsCheck
│
├─── Actor A sets "ReadyToX" status
├─── Actor B sets "ReadyToY" status
├─── Actor C sets "ReadyToZ" status
│
▼ When ALL ready
│
├─→ Actor A.Send("Go")
├─→ Actor B.Send("Go")
└─→ Actor C.Send("Go")
Method Signature
bool SyncRequestsCheck(
IActor actor, // The calling actor (Syncer)
Dictionary<string, object?> grants, // Tracks which grants have been issued
RequestSource[] requestSources, // Array of conditions to check
string grantName // Unique identifier for this sync point
)
Returns: true if all conditions are met and "go" messages have been sent
RequestSource Definition
new RequestSource(
targetActor, // Actor to check/send to
"ReadyStatusKey", // Status key to check (e.g., "ReadyToLoad")
"GoMessage", // Message to send when ready (e.g., "Load")
messagePayload // Optional payload for the message
)
Typical Usage Pattern
In Central Orchestrator (Syncer)
// In AutoState.cs, scan loop continuously checks sync points
private bool Scan()
{
// Synchronization Point 1: Transfer from Loader to Table0
if (_transferOnTableRequestId == Guid.Empty) // Guard against duplicate
{
if (SyncUtils.SyncRequestsCheck(
Actor,
_grants,
[
new RequestSource(
Actor.Peers.Loader,
"ReadyToUnload", // Loader must be ready to unload
"Unload", // Send "Unload" message
new Dict { ["index"] = 0 }
),
new RequestSource(
Actor.Peers.Table0,
"ReadyToLoad", // Table0 must be ready to load
"Load", // Send "Load" message
null
)
],
"UnloadToTable0" // Grant name
))
{
// Track the request to prevent duplicate triggers
_transferOnTableRequestId = _requestId;
}
}
// More sync points...
return false;
}
In Participating Actors
Each actor continuously sets its readiness status based on internal state:
// In TableActor AutoState
private bool Scan()
{
if (!Actor.Exist.Value)
{
// No material - ready to receive
if (_readyToLoadId == Guid.Empty)
{
_readyToLoadId = Guid.NewGuid();
Actor.SetStatusByActor(Actor.Syncer, "ReadyToLoad", _readyToLoadId);
}
}
else if (!Actor.Trimmed.Value)
{
// Has material, not processed - ready to trim
if (_readyToTrimId == Guid.Empty)
{
_readyToTrimId = Guid.NewGuid();
Actor.SetStatusByActor(Actor.Syncer, "ReadyToTrim", _readyToTrimId);
}
}
else
{
// Processed - ready to unload
if (_readyToUnloadId == Guid.Empty)
{
_readyToUnloadId = Guid.NewGuid();
Actor.SetStatusByActor(Actor.Syncer, "ReadyToUnload", _readyToUnloadId);
}
}
return false;
}
Complete Lifecycle
Phase 1: Readiness Signaling
Actors continuously evaluate their state and signal readiness:
Loader (has material) → SetStatusByActor(Syncer, "ReadyToUnload", Guid)
Table0 (empty) → SetStatusByActor(Syncer, "ReadyToLoad", Guid)
Phase 2: Syncer Polling
Syncer continuously checks conditions:
SyncRequestsCheck(
Actor,
_grants,
[
new RequestSource(Loader, "ReadyToUnload", "Unload", new Dict{["index"]=0}),
new RequestSource(Table0, "ReadyToLoad", "Load", null)
],
"UnloadToTable0"
)
Internal logic:
- Checks if
Loader.GetPeerStatusByActor(Syncer, "ReadyToUnload")is truthy (Guid) - Checks if
Table0.GetPeerStatusByActor(Syncer, "ReadyToLoad")is truthy (Guid) - If both true AND grant not yet issued:
- Send
Loader.Send("Unload", new Dict{["index"]=0}) - Send
Table0.Send("Load") - Mark grant as issued in
_grantsdictionary - Return
true
- Send
Phase 3: Operation Execution
Both actors receive their "go" messages simultaneously:
Loader receives "Unload" → Enters UnloadingState → Performs handshake
Table0 receives "Load" → Enters LoadingState → Performs handshake
Phase 4: Completion and Reset
After operation completes:
// In LoadingState.Dispose()
Actor.SetStatusByActor(Actor.Syncer, "ReadyToLoad", false); // Clear status
_readyToLoadId = Guid.Empty;
// When Syncer receives CycleDone message
_transferOnTableRequestId = Guid.Empty; // Clear request tracking
_grants.Remove("UnloadToTable0"); // Clear grant
Grant Management
Purpose
The grants dictionary prevents duplicate triggering:
private Dictionary<string, object?> _grants = new();
Without grants: If conditions remain true, SyncRequestsCheck would send "go" messages every scan cycle.
With grants: Once a grant is issued for a sync point, no more messages are sent until the grant is cleared.
Grant Lifecycle
// Initial state
_grants["UnloadToTable0"] → does not exist → messages sent, grant created
// Next scan (conditions still true)
_grants["UnloadToTable0"] → exists → no messages sent
// After completion
_grants.Remove("UnloadToTable0") → grant cleared → ready for next cycle
Request ID Tracking
For long-running transfers, track the request to coordinate completion:
private Guid _transferOnTableRequestId = Guid.Empty;
// When sync succeeds
_transferOnTableRequestId = _requestId;
// Guard condition
if (_transferOnTableRequestId == Guid.Empty)
{
// Only check sync if no transfer in progress
if (SyncUtils.SyncRequestsCheck(...))
{
_transferOnTableRequestId = _requestId;
}
}
// On completion (receiving CycleDone message)
_transferOnTableRequestId = Guid.Empty;
Common Synchronization Points
Two-Party Transfer
Pattern: Material moves from Actor A to Actor B
SyncUtils.SyncRequestsCheck(
Actor,
_grants,
[
new RequestSource(actorA, "ReadyToUnload", "Unload", payload),
new RequestSource(actorB, "ReadyToLoad", "Load", null)
],
"TransferAtoB"
)
Examples:
- Loader → Table:
UnloadToTable0,UnloadToTable1 - Table → Unloader:
LoadFromTable0,LoadFromTable1 - PreLoader → Buffer:
UnloadToBuffer - Buffer → Loader:
LoadFromBuffer
Three-Party Coordination
Pattern: Master coordinator + two subordinates
SyncUtils.SyncRequestsCheck(
Actor,
_grants,
[
new RequestSource(BufferMaster, "ReadyToSwap", "Swap", null),
new RequestSource(Buffer0, "ReadyToSwap", "ShiftBuffer", null),
new RequestSource(Buffer1, "ReadyToSwap", "ShiftBuffer", null)
],
"SwapBuffers"
)
Example: Buffer swap coordination where:
- BufferMaster coordinates overall swap
- Buffer0 must be ready to swap
- Buffer1 must be ready to swap
Single-Actor Operation
Pattern: Syncer triggers an actor to perform internal operation
SyncUtils.SyncRequestsCheck(
Actor,
_grants,
[
new RequestSource(Table0, "ReadyToTrim", "Trim", new Dict())
],
"TrimOnTable0"
)
Examples:
TrimOnTable0: Trigger cutting operationTrimOnTable1: Trigger cutting operation
Comparison with Direct Messaging
Direct Messages (actor.Send)
Use when:
- One-shot commands
- Request/response pairs
- Fire-and-forget notifications
Example:
auxiliaryActor.Send(new Message(this, "TurnOnLight"));
SyncRequestsCheck Pattern
Use when:
- Multiple actors must coordinate
- All parties must reach ready state
- Need to prevent race conditions
- Operations are expensive/irreversible
Benefits:
- Prevents race conditions (all or nothing)
- Central coordination point
- Grant system prevents duplicate triggers
- Easy to visualize in orchestrator
Real-World Example: FITO Trimming System
Material Flow with 11 Sync Points
[1] LoadFromElevator
LoadElevator.ReadyToTransfer + PreLoader.ReadyToLoad
→ Transfer, Load
[2] UnloadToBuffer
PreLoader.ReadyToUnload + Buffer.ReadyToLoad
→ Unload(idx), Load
[3] SwapBuffers (if needed)
BufferMaster.ReadyToSwap + Buffer0.ReadyToSwap + Buffer1.ReadyToSwap
→ Swap
[4] LoadFromBuffer
Buffer.ReadyToUnload + Loader.ReadyToLoad
→ Unload, Load(idx)
[5/6] UnloadToTable0 or UnloadToTable1
Loader.ReadyToUnload + Table.ReadyToLoad
→ Unload(idx), Load
[7/8] TrimOnTable0 or TrimOnTable1
Table.ReadyToTrim
→ Trim
[9/10] LoadFromTable0 or LoadFromTable1
Table.ReadyToUnload + Unloader.ReadyToLoad
→ Unload, Load(idx)
[11] UnloadToElevator
Unloader.ReadyToUnload + UnloadElevator.ReadyToTransfer
→ Unload, Transfer
Complete Sync Point Implementation
File: fito_trimming_2025/Actors/Syncer/AutoState.cs
private bool Scan()
{
// [5] UnloadToTable0 - Loader delivers material to Table0
if (_transferOnTableRequestId == Guid.Empty)
{
if (SyncUtils.SyncRequestsCheck(
Actor,
_grants,
[
new RequestSource(
Actor.Peers.Loader,
"ReadyToUnload",
"Unload",
new Dict { ["index"] = 0 }
),
new RequestSource(
Actor.Peers.Table0,
"ReadyToLoad",
"Load",
null
)
],
"UnloadToTable0"
))
{
_transferOnTableRequestId = _requestId;
}
}
// [7] TrimOnTable0 - Table0 performs cutting operation
if (SyncUtils.SyncRequestsCheck(
Actor,
_grants,
[
new RequestSource(
Actor.Peers.Table0,
"ReadyToTrim",
"Trim",
new Dict()
)
],
"TrimOnTable0"
))
{
// No request tracking needed for single-actor operation
}
// [9] LoadFromTable0 - Unloader picks from Table0
if (_transferOnTableRequestId == Guid.Empty)
{
if (SyncUtils.SyncRequestsCheck(
Actor,
_grants,
[
new RequestSource(
Actor.Peers.Table0,
"ReadyToUnload",
"Unload",
null
),
new RequestSource(
Actor.Peers.Unloader,
"ReadyToLoad",
"Load",
new Dict { ["index"] = 0 }
)
],
"LoadFromTable0"
))
{
_transferOnTableRequestId = _requestId;
}
}
return false;
}
Best Practices
1. Use Unique Grant Names
// Good - specific and unique
"UnloadToTable0"
"LoadFromTable1"
"SwapBuffers"
// Bad - generic and ambiguous
"Transfer"
"Process"
"Sync"
2. Guard Long Operations with Request IDs
// Prevent overlapping transfers
if (_transferOnTableRequestId == Guid.Empty)
{
if (SyncUtils.SyncRequestsCheck(...))
{
_transferOnTableRequestId = _requestId;
}
}
3. Clear Statuses on State Exit
public override void Dispose()
{
Actor.SetStatusByActor(Actor.Syncer, "ReadyToLoad", false);
Actor.SetStatusByActor(Actor.Syncer, "ReadyToUnload", false);
_readyToLoadId = Guid.Empty;
_readyToUnloadId = Guid.Empty;
}
4. Use Guid for Status Values
// Good - use Guid to prevent false positives
if (_readyToLoadId == Guid.Empty)
{
_readyToLoadId = Guid.NewGuid();
Actor.SetStatusByActor(Actor.Syncer, "ReadyToLoad", _readyToLoadId);
}
// Bad - boolean can't detect if status is stale
Actor.SetStatusByActor(Actor.Syncer, "ReadyToLoad", true);
5. Clear Grants After Completion
// In Syncer, when receiving CycleDone
case "CycleDone":
_transferOnTableRequestId = Guid.Empty;
_grants.Clear(); // Or selectively remove grants
return true;
Debugging Tips
Log Sync Points
if (SyncUtils.SyncRequestsCheck(...))
{
Logger.Debug($"Sync succeeded: {grantName}");
_requestId = ...;
}
Inspect Readiness Statuses
var loaderReady = Actor.GetPeerStatusByActor(Loader, "ReadyToUnload");
var tableReady = Actor.GetPeerStatusByActor(Table0, "ReadyToLoad");
Logger.Debug($"Loader: {loaderReady}, Table: {tableReady}");
Monitor Grant State
Logger.Debug($"Active grants: {string.Join(", ", _grants.Keys)}");
Track Request IDs
Logger.Debug($"Current transfer request: {_transferOnTableRequestId}");
Summary
| Concept | Purpose |
|---|---|
SyncUtils.SyncRequestsCheck | Coordinate multi-actor operations with all-or-nothing semantics |
RequestSource | Define a condition (status) and action (message) for one participant |
| Grant dictionary | Prevent duplicate triggers while conditions remain true |
| Request ID tracking | Coordinate completion of long-running transfers |
| Guid-based statuses | Detect stale statuses and prevent false positives |
| SetStatusByActor | Signal readiness to the orchestrator |
| GetPeerStatusByActor | Check readiness of participants |
The SyncRequestsCheck pattern provides race-condition-free coordination for complex multi-actor operations where timing, safety, and orchestration are critical.