Core Concepts

Handler Types

Acton provides four handler types to cover different combinations of state access and error handling. Choosing the right one affects both correctness and performance.


Quick Reference

HandlerState AccessCan FailConcurrency
mutate_onMutableNoSequential
act_onRead-onlyNoConcurrent
try_mutate_onMutableYesSequential
try_act_onRead-onlyYesConcurrent

Rule of thumb: Start with mutate_on. Use act_on when you're sure the handler only reads. Add try_ prefix when you need error handling.


mutate_on

Use when you need to modify actor state.

actor.mutate_on::<UpdateCounter>(|actor, ctx| {
    // Mutable access to state
    actor.model.counter += ctx.message().increment;
    actor.model.last_updated = Instant::now();

    Reply::ready()
});

Characteristics:

  • Exclusive access to actor.model (mutable reference)
  • Handlers execute one at a time (sequential)
  • Cannot return errors (infallible)
  • Next message waits until current handler completes

Execution model:

Each message completes before the next starts.

When to use:

  • State mutations (counters, adding to collections, updating fields)
  • Any operation where order matters
  • When you're unsure - this is the safe default

act_on

Use for read-only operations that can run concurrently.

actor.act_on::<GetStatus>(|actor, ctx| {
    // Read-only access to state
    let status = actor.model.status.clone();
    let reply = ctx.reply_envelope();

    Reply::pending(async move {
        reply.send(StatusResponse(status)).await;
    })
});

Characteristics:

  • Shared (read-only) access to actor.model
  • Multiple handlers can run concurrently
  • Cannot return errors (infallible)
  • Great for queries and notifications

Execution model:

When to use:

  • Queries that don't modify state
  • Sending notifications/replies
  • Heavy read operations (can parallelize)

High-Water Mark

The maximum concurrent handlers is configurable:

# config.toml
[limits]
concurrent_handlers_high_water_mark = 100

When the limit is reached:

  1. Actor waits for all concurrent handlers to complete
  2. Then processes the next batch

try_mutate_on

Use when state mutation can fail.

actor.try_mutate_on::<ProcessPayment>(|actor, ctx| {
    let amount = ctx.message().amount;
    let balance = actor.model.balance;

    if balance < amount {
        // Immediate error
        Reply::try_err(InsufficientFunds { balance, required: amount })
    } else {
        actor.model.balance -= amount;
        // Immediate success
        Reply::try_ok(PaymentSuccess { remaining: actor.model.balance })
    }
});

With async operations:

actor.try_mutate_on::<ProcessPayment>(|actor, ctx| {
    let amount = ctx.message().amount;
    let balance = actor.model.balance;
    let payment_service = actor.model.payment_service.clone();

    Reply::try_pending(async move {
        if balance < amount {
            Err(InsufficientFunds { balance, required: amount })
        } else {
            payment_service.charge(amount).await?;
            Ok(PaymentSuccess { remaining: balance - amount })
        }
    })
});

Characteristics:

  • Mutable access with error handling
  • Sequential execution (like mutate_on)
  • Requires error handler registration with on_error
  • Use for operations that can fail

When to use:

  • Validation before mutation
  • External service calls that might fail
  • Operations with preconditions

try_act_on

Use for concurrent read-only operations that can fail.

actor.try_act_on::<ValidateToken>(|actor, ctx| {
    let token = ctx.message().token.clone();
    let validator = actor.model.validator.clone();

    Reply::try_pending(async move {
        let is_valid = validator.validate(&token).await?;
        Ok(ValidationResult { valid: is_valid })
    })
});

With immediate result:

actor.try_act_on::<CheckCache>(|actor, ctx| {
    let key = ctx.message().key.clone();

    match actor.model.cache.get(&key) {
        Some(value) => Reply::try_ok(CacheHit { value: value.clone() }),
        None => Reply::try_err(CacheMiss { key }),
    }
});

Characteristics:

  • Read-only access with error handling
  • Concurrent execution (like act_on)
  • Requires error handler registration
  • Use for fallible queries

Error Handler Registration

When using try_* handlers, register error handlers with on_error:

// Define error type
#[derive(Debug)]
struct ValidationError(String);

impl std::error::Error for ValidationError {}
impl std::fmt::Display for ValidationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Validation error: {}", self.0)
    }
}

// Register fallible handler
actor.try_mutate_on::<ProcessData>(|actor, ctx| {
    let data = ctx.message().data.clone();

    if data.is_empty() {
        Reply::try_err(ValidationError("Empty data".into()))
    } else {
        actor.model.data = data;
        Reply::try_ok(ProcessSuccess)
    }
});

// Register error handler
actor.on_error::<ProcessData, ValidationError>(|actor, ctx, error| {
    println!("Validation failed: {}", error.0);
    // Optionally update state, send notifications, etc.
    Reply::ready()
});

If no error handler is registered, errors are logged and the message is dropped.


Reply Types Summary

Handler TypeSync ReturnAsync Return
mutate_on / act_onReply::ready()Reply::pending(async { })
try_mutate_on / try_act_onReply::try_ok(val) or Reply::try_err(err)Reply::try_pending(async { Ok/Err })

Choosing the Right Handler

Examples by Use Case

Use CaseHandlerReason
Increment countermutate_onModifies state, can't fail
Get current valueact_onRead-only, can't fail
Deduct from balancetry_mutate_onModifies state, can fail (insufficient funds)
Validate API keytry_act_onRead-only, can fail (invalid key)
Log a messagemutate_onModifies state (log buffer)
Send notificationact_onRead-only (just reading data to send)

Performance Considerations

Use act_on for Read-Heavy Workloads

// Bad: serializes all queries
actor.mutate_on::<GetData>(|actor, ctx| { ... });

// Good: queries run concurrently
actor.act_on::<GetData>(|actor, ctx| { ... });

Keep Handlers Lightweight

// Bad: heavy computation blocks other messages
actor.mutate_on::<Process>(|actor, ctx| {
    let result = expensive_computation(); // Blocks!
    actor.model.result = result;
    Reply::ready()
});

// Good: offload to async
actor.mutate_on::<Process>(|actor, ctx| {
    let data = actor.model.input.clone();

    Reply::pending(async move {
        let result = tokio::task::spawn_blocking(move || {
            expensive_computation(&data)
        }).await.unwrap();
        // Note: can't update actor.model here
        // Send result to self if needed
    })
});
// Instead of many small messages
#[acton_message]
struct IncrementBy(u32);  // Single message with amount

// Rather than
#[acton_message]
struct Increment;  // Sending this 1000 times

Next Steps

Previous
Messages & handlers