Skip to content

Latest commit

 

History

History
652 lines (511 loc) · 20.7 KB

File metadata and controls

652 lines (511 loc) · 20.7 KB

Grain Definition

Complete guide to the grain { } computation expression.

What you'll learn

  • Every keyword in the grain { } CE
  • How to define handlers, lifecycle hooks, reminders, and timers
  • Allowing message types to interleave with interleaveMessage
  • Multiple named states, and where per-grain Orleans attributes live (the C# CodeGen path)

Overview

The grain { } CE builds a GrainDefinition<'State, 'Message> -- an immutable record that fully describes a grain's behavior. The Orleans.FSharp.CodeGen package reads this definition at build time and generates the corresponding C# grain class with all the correct Orleans attributes.

open Orleans.FSharp

let myGrain = grain {
    defaultState initialValue
    handle handlerFunction
    persist "StorageProviderName"
}

Every grain definition requires at minimum:

  1. A defaultState — the initial state value
  2. At least one handler from the table below

Handler variant quick reference

Variant Signature Use when
handle 'S -> 'M -> Task<'S * obj> Full control, manual box
handleState 'S -> 'M -> Task<'S> Caller only needs state
handleTyped 'S -> 'M -> Task<'S * 'R> Typed result, no box
handleWithContext GrainContext -> 'S -> 'M -> Task<'S * obj> Need grain-to-grain calls or DI
handleStateWithContext GrainContext -> 'S -> 'M -> Task<'S> Context + state-only return
handleTypedWithContext GrainContext -> 'S -> 'M -> Task<'S * 'R> Context + typed result
handleCancellable 'S -> 'M -> CancellationToken -> Task<'S * obj> Long-running ops, manual box
handleStateCancellable 'S -> 'M -> CancellationToken -> Task<'S> Long-running ops, state-only
handleTypedCancellable 'S -> 'M -> CancellationToken -> Task<'S * 'R> Long-running ops, typed result
handleWithContextCancellable GrainContext -> 'S -> 'M -> CancellationToken -> Task<'S * obj> Context + cancellation
handleStateWithContextCancellable GrainContext -> 'S -> 'M -> CancellationToken -> Task<'S> Context + cancellation, state-only
handleTypedWithContextCancellable GrainContext -> 'S -> 'M -> CancellationToken -> Task<'S * 'R> Context + cancellation, typed result

Aliases: handleWithServices = handleWithContext, handleStateWithServices = handleStateWithContext, handleWithServicesCancellable = handleWithContextCancellable, handleStateWithServicesCancellable = handleStateWithContextCancellable, handleTypedWithServicesCancellable = handleTypedWithContextCancellable.


State and Handlers

defaultState

Sets the initial state of the grain when first activated. Required for every grain.

let myGrain = grain {
    defaultState 0
    handle (fun state msg -> task { return state + 1, box(state + 1) })
}

For DU state:

let myGrain = grain {
    defaultState Zero
    handle (fun state msg -> task { return Count 1, box 1 })
}

handle

Registers the message handler. Takes the current state and a message, returns a Task<'State * obj> (new state and a boxed result).

grain {
    defaultState 0
    handle (fun state msg ->
        task {
            match msg with
            | Add n -> return state + n, box(state + n)
            | Get -> return state, box state
        })
}

handleState

A simpler variant of handle for grains where the caller only needs the updated state. The handler returns Task<'State> — no need to box a result separately.

grain {
    defaultState { Count = 0 }
    handleState (fun state msg ->
        task {
            match msg with
            | Increment -> return { Count = state.Count + 1 }
            | Decrement -> return { Count = state.Count - 1 }
            | GetValue  -> return state
        })
}

The result returned to the caller is the new state (boxed internally).

handleTyped

Like handle, but accepts a strongly-typed result instead of obj. No manual box call needed.

grain {
    defaultState { Count = 0 }
    handleTyped (fun state msg ->
        task {
            match msg with
            | Increment ->
                let ns = { Count = state.Count + 1 }
                return ns, ns.Count    // 'Result = int — no box needed
            | GetValue ->
                return state, state.Count
        })
}

The 'Result type is inferred from the handler return type. The framework boxes it internally before sending to the caller.


handleWithContext

Like handle, but the handler receives a GrainContext as the first argument. Use this when you need to call other grains or resolve DI services from within the handler.

grain {
    defaultState Map.empty
    handleWithContext (fun ctx state msg ->
        task {
            match msg with
            | Aggregate key ->
                let otherGrain =
                    GrainContext.getGrainByString<IOtherGrain> ctx key
                let! value = GrainRef.invoke otherGrain (fun g -> g.GetValue())
                return state |> Map.add key value, box value
        })
}

The GrainContext provides:

Member Description
GrainContext.getService<'T> Resolve a DI service
GrainContext.getState<'T> name Get a named additional persistent state
GrainContext.getGrainByString<'T> key Get a grain ref by string key
GrainContext.getGrainByGuid<'T> key Get a grain ref by GUID key
GrainContext.getGrainByInt64<'T> key Get a grain ref by int64 key
GrainContext.getGrainByGuidCompound<'T> guid ext Compound GUID+string key
GrainContext.getGrainByIntCompound<'T> key ext Compound int64+string key
GrainContext.deactivateOnIdle Request deactivation when idle
GrainContext.delayDeactivation span Delay deactivation
GrainContext.grainId Get the GrainId
GrainContext.primaryKeyString Get the string primary key
GrainContext.primaryKeyGuid Get the Guid primary key
GrainContext.primaryKeyInt64 Get the int64 primary key

handleStateWithContext

Like handleWithContext, but returns only the new state — no need to manually box a result. Use this when the handler needs GrainContext but the caller only needs the updated state.

grain {
    defaultState { Score = 0 }
    handleStateWithContext (fun ctx state msg ->
        task {
            match msg with
            | AddPoints n ->
                return { Score = state.Score + n }
            | SubtractPoints n ->
                return { Score = state.Score - n }
            | NotifyAchievement achievementId ->
                let notifier = GrainContext.getService<IAchievementNotifier> ctx
                do! notifier.NotifyAsync(achievementId)
                return state
        })
}

Pair with FSharpGrain.send to receive the updated state back.

handleTypedWithContext

Like handleWithContext, but returns a strongly-typed result — no manual box call needed. Combines context access with the clean handleTyped return style.

grain {
    defaultState { Balance = 0m }
    handleTypedWithContext (fun ctx state msg ->
        task {
            match msg with
            | Deposit amount ->
                let ns = { Balance = state.Balance + amount }
                return ns, ns.Balance          // 'Result = decimal
            | GetBalance ->
                return state, state.Balance
        })
}

Pair with FSharpGrain.ask<'S,'C,decimal> to receive the typed result.

handleWithServices

Alias for handleWithContext that emphasizes DI access. Identical behavior.

grain {
    defaultState []
    handleWithServices (fun ctx state msg ->
        task {
            let logger = GrainContext.getService<ILogger<_>> ctx
            Log.logInfo logger "Processing {Command}" [| box msg |]
            return state, box ()
        })
}

handleCancellable

Like handle, but the handler receives a CancellationToken for cooperative cancellation of long-running operations.

grain {
    defaultState ""
    handleCancellable (fun state msg ct ->
        task {
            let! result = longRunningOperation ct
            return result, box result
        })
}

handleStateCancellable

Like handleState, but the handler receives a CancellationToken. Returns only the updated state — no manual box needed.

grain {
    defaultState { Items = [] }
    handleStateCancellable (fun state msg ct ->
        task {
            match msg with
            | FetchAndAppend url ->
                let! item = fetchWithTimeout url ct
                return { Items = item :: state.Items }
            | Clear ->
                return { Items = [] }
        })
}

The CancellationToken comes from the Orleans runtime and allows long-running fetch/IO operations to be cancelled cleanly when the silo shuts down.

handleTypedCancellable

Like handleTyped, but the handler also receives a CancellationToken. Returns a strongly-typed result — no box needed.

grain {
    defaultState { Results = [] }
    handleTypedCancellable (fun state msg ct ->
        task {
            match msg with
            | ComputeSum inputs ->
                let sum = List.sum inputs
                return { Results = sum :: state.Results }, sum   // 'Result = int
            | GetLatest ->
                let latest = state.Results |> List.tryHead |> Option.defaultValue 0
                return state, latest
        })
}

Pair with FSharpGrain.ask<'S,'C,int> to receive the typed result.

handleWithContextCancellable

Combines GrainContext and CancellationToken.

grain {
    defaultState 0
    handleWithContextCancellable (fun ctx state msg ct ->
        task {
            let httpClient = GrainContext.getService<HttpClient> ctx
            let! response = httpClient.GetAsync("https://api.example.com", ct)
            return state + 1, box response.StatusCode
        })
}

handleWithServicesCancellable

Alias for handleWithContextCancellable.

handleStateWithContextCancellable

Combines GrainContext, CancellationToken, and state-only return — no manual box needed. The maximum set of inputs with the simplest return.

grain {
    defaultState { Items = [] }
    handleStateWithContextCancellable (fun ctx state msg ct ->
        task {
            match msg with
            | FetchAndStore url ->
                let http = GrainContext.getService<HttpClient> ctx
                let! body = http.GetStringAsync(url, ct)
                return { Items = body :: state.Items }
            | Clear ->
                return { Items = [] }
        })
}

handleStateWithServicesCancellable

Alias for handleStateWithContextCancellable.

handleTypedWithContextCancellable

The full combination: GrainContext, CancellationToken, and a typed result — no box needed.

grain {
    defaultState { Total = 0 }
    handleTypedWithContextCancellable (fun ctx state msg ct ->
        task {
            match msg with
            | FetchAndAdd url ->
                let http = GrainContext.getService<HttpClient> ctx
                let! n = http.GetStringAsync(url, ct) |> Task.map int
                return { Total = state.Total + n }, state.Total + n  // 'Result = int
            | GetTotal ->
                return state, state.Total
        })
}

Pair with FSharpGrain.ask<'S,'C,int> to receive the typed result.

handleTypedWithServicesCancellable

Alias for handleTypedWithContextCancellable.


Persistence

persist

Names the Orleans storage provider for state persistence. The provider must be registered in the silo configuration.

grain {
    defaultState Zero
    handle (fun state msg -> task { return Count 1, box 1 })
    persist "Default"
}

Without persist, the grain state is in-memory only and is lost on deactivation.

additionalState

Declares a named secondary persistent state that can be accessed via GrainContext.getState. Use this when a grain needs multiple independently persisted state values.

grain {
    defaultState { Balance = 0m }

    additionalState "AuditLog" "AuditStore" ([] : AuditEntry list)

    handleWithContext (fun ctx state msg ->
        task {
            let auditState =
                GrainContext.getState<AuditEntry list> ctx "AuditLog"
            let currentAudit = auditState.State
            // ... process and update both states
            return newState, box result
        })

    persist "Default"
}

Parameters: additionalState name storageName defaultValue


Lifecycle Hooks

onActivate

Runs when the grain is activated. Receives the current state and returns a potentially modified state.

grain {
    defaultState { Cache = Map.empty; LastRefresh = DateTime.MinValue }
    handle myHandler
    persist "Default"

    onActivate (fun state ->
        task {
            printfn "Grain activated with state: %A" state
            return { state with LastRefresh = DateTime.UtcNow }
        })
}

onDeactivate

Runs when the grain is being deactivated. Receives the current state for cleanup.

grain {
    defaultState { ConnectionId = None }
    handle myHandler
    persist "Default"

    onDeactivate (fun state ->
        task {
            match state.ConnectionId with
            | Some id -> printfn "Closing connection %s" id
            | None -> ()
        })
}

onLifecycleStage

Hooks into Orleans grain lifecycle stages for fine-grained control. Standard stages:

Stage Value Description
GrainLifecycleStage.First 2000 First stage after creation
GrainLifecycleStage.SetupState 4000 State setup
GrainLifecycleStage.Activate 6000 Activation
GrainLifecycleStage.Last int.MaxValue Final stage
grain {
    defaultState 0
    handle myHandler

    onLifecycleStage 2000 (fun ct ->
        task {
            printfn "Early lifecycle hook firing"
        })

    onLifecycleStage 6000 (fun ct ->
        task {
            printfn "Activation-stage hook firing"
        })
}

Multiple hooks at the same stage are executed in registration order.


Reminders and Timers

onReminder

Registers a named reminder handler. Reminders are persistent periodic triggers that survive grain deactivation and silo restarts. The silo must have a reminder service configured.

grain {
    defaultState { CheckCount = 0 }
    handle myHandler
    persist "Default"

    onReminder "HealthCheck" (fun state reminderName tickStatus ->
        task {
            printfn "Reminder %s fired at %A" reminderName tickStatus.CurrentTickTime
            return { state with CheckCount = state.CheckCount + 1 }
        })
}

To register the reminder at runtime, use the Reminder module:

Reminder.register grain "HealthCheck" (TimeSpan.FromMinutes 1.) (TimeSpan.FromMinutes 5.)

onTimer

Registers a declarative timer that is automatically started on grain activation and stopped on deactivation. Timers are in-memory only -- they do not survive deactivation.

grain {
    defaultState { HeartbeatCount = 0 }
    handle myHandler

    onTimer
        "Heartbeat"
        (TimeSpan.FromSeconds 10.)    // dueTime: first fire after 10s
        (TimeSpan.FromSeconds 30.)    // period: then every 30s
        (fun state ->
            task {
                return { state with HeartbeatCount = state.HeartbeatCount + 1 }
            })
}

Reentrancy and Concurrency

By default each grain processes one message at a time. Under the universal grain pattern the one concurrency lever is interleaveMessage.

interleaveMessage

Registers a message type whose calls may interleave with other calls on the same grain — the working replacement for the removed string-based mayInterleave keyword.

grain {
    defaultState Map.empty
    handle myHandler
    interleaveMessage typeof<Query>     // allow all Query cases to interleave
}

All F# grains share Orleans.FSharp.FSharpGrainImpl, which carries a single class-level [MayInterleave] predicate keyed on the incoming message's runtime type. Each registered type is pushed into the process-wide FSharpInterleaveRegistry when the grain is added to the silo. Pass the discriminated-union type itself — the predicate matches by assignability, so field-carrying DU cases (which compile to nested subtypes that cannot be named in source) are covered. Repeated registrations of the same type are de-duplicated.

Caution: registering a broad base type, interface, or obj will make every assignable message type interleavable — always register specific DU or message types.


Per-grain Orleans attributes (C# CodeGen path)

Reentrancy beyond interleaveMessage, stateless workers, placement strategies, one-way and read-only methods, implicit stream subscriptions, and custom grain-type names are not grain { } CE keywords. The universal grain pattern shares a single FSharpGrainImpl class and one handler method, so per-grain class-level or per-method attributes cannot be expressed there.

To use them, define the grain through the per-grain Orleans.FSharp.CodeGen path: each grain compiles to its own C# class/method that carries the real Orleans attribute.

Concept Orleans attribute (CodeGen path)
Reentrant grain [Reentrant]
Custom interleave predicate [MayInterleave("Predicate")]
Always-interleaved / read-only method [AlwaysInterleave] / [ReadOnly]
Stateless worker (+ max activations) [StatelessWorker] / [StatelessWorker(n)]
Fire-and-forget method [OneWay]
Custom grain type name [GrainType("name")]
Implicit stream subscription [ImplicitStreamSubscription("namespace")]
Placement [PreferLocalPlacement], [RandomPlacement], [HashBasedPlacement], [ActivationCountBasedPlacement], [ResourceOptimizedPlacement], [SiloRoleBasedPlacement], or a custom PlacementStrategy

Per-grain idle deactivation timeouts are configured globally with grainCollectionAge in siloConfig { }; there is no per-grain grain { } keyword for it.


Complete Example

Here is a grain that uses many features together:

open System
open System.Threading.Tasks
open Orleans.FSharp

[<GenerateSerializer>]
type ChatState =
    | [<Id(0u)>] Empty
    | [<Id(1u)>] Active of messages: string list * participants: Set<string>

[<GenerateSerializer>]
type ChatCommand =
    | [<Id(0u)>] Join of user: string
    | [<Id(1u)>] Leave of user: string
    | [<Id(2u)>] Send of user: string * text: string
    | [<Id(3u)>] GetHistory
    | [<Id(4u)>] GetParticipants

let chatRoom =
    grain {
        defaultState Empty

        handleWithContext (fun ctx state cmd ->
            task {
                match state, cmd with
                | Empty, Join user ->
                    let newState = Active([], Set.singleton user)
                    return newState, box true
                | Active(msgs, users), Join user ->
                    return Active(msgs, users |> Set.add user), box true
                | Active(msgs, users), Leave user ->
                    let remaining = users |> Set.remove user
                    if Set.isEmpty remaining then
                        return Empty, box true
                    else
                        return Active(msgs, remaining), box true
                | Active(msgs, users), Send(user, text) ->
                    let entry = $"[{DateTime.UtcNow:HH:mm}] {user}: {text}"
                    return Active(entry :: msgs, users), box entry
                | _, GetHistory ->
                    let msgs = match state with Active(m, _) -> m | _ -> []
                    return state, box msgs
                | _, GetParticipants ->
                    let users = match state with Active(_, u) -> u | _ -> Set.empty
                    return state, box users
                | _ -> return state, box false
            })

        persist "Default"
        interleaveMessage typeof<ChatCommand>   // read-heavy queries may interleave

        onTimer "Cleanup" (TimeSpan.FromMinutes 5.) (TimeSpan.FromMinutes 5.)
            (fun state ->
                task {
                    match state with
                    | Active(msgs, users) when msgs.Length > 1000 ->
                        return Active(msgs |> List.take 500, users)
                    | _ -> return state
                })
    }

Next steps

  • Silo Configuration -- configure storage, clustering, and streaming for your grains
  • Streaming -- publish and subscribe to events
  • Testing -- test your grain definitions with FsCheck and TestHarness
  • Advanced -- transactions, grain directory, OpenTelemetry, shutdown, state migration