Skip to main content

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:

  1. Checks if Loader.GetPeerStatusByActor(Syncer, "ReadyToUnload") is truthy (Guid)
  2. Checks if Table0.GetPeerStatusByActor(Syncer, "ReadyToLoad") is truthy (Guid)
  3. 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 _grants dictionary
    • Return true

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 operation
  • TrimOnTable1: 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

ConceptPurpose
SyncUtils.SyncRequestsCheckCoordinate multi-actor operations with all-or-nothing semantics
RequestSourceDefine a condition (status) and action (message) for one participant
Grant dictionaryPrevent duplicate triggers while conditions remain true
Request ID trackingCoordinate completion of long-running transfers
Guid-based statusesDetect stale statuses and prevent false positives
SetStatusByActorSignal readiness to the orchestrator
GetPeerStatusByActorCheck readiness of participants

The SyncRequestsCheck pattern provides race-condition-free coordination for complex multi-actor operations where timing, safety, and orchestration are critical.

See Also