This document describes the target API. Implementation progress: ../ROADMAP.md.
Only three.
Grain — a stateful object with a GrainId. You write a Go struct plus a set of methods. The Runtime guarantees Calls for the same Grain run serially.
GrainId — a GrainType plus a GrainKey. Account("alice") and
Account("bob") are two different Grains. Account("alice") always names
the same Grain. No create or delete call is needed. The Grain starts at its
first Call, may leave memory after idle time, and keeps State in the store.
Call - a method invocation through a Grain Reference. The caller does not manage the target Activation.
Write the interface first:
//gor:grain
type Account interface {
Deposit(ctx context.Context, amount int64) (int64, error)
Balance(ctx context.Context) (int64, error)
}The Grain interface and its methods must be exported. The interface cannot
have type parameters, and a method cannot be variadic. The first parameter of
each method must be context.Context. The last result must be error. Every
contract type must be accessible from the generated subpackage. An invalid
contract is a generation error that names the source line.
The //gor:grain marker says this interface gets typed Calls generated for
it. Add gor and its generator to your module once:
go get github.com/suraciii/gor
go get -tool github.com/suraciii/gor/cmd/gorgenKeep one command in the Grain package:
//go:generate go tool gorgen -pkg .Run go generate ./... after a marked interface changes. CI can run
go tool gorgen -pkg ./path/to/grains -check to reject missing or stale
output. The default output is the gorgen subpackage under the Grain package.
See ../design/codegen.md.
Every Runtime must install the generated output at startup before Grains can be registered or Grain References can be obtained.
Then write the implementation:
type account struct {
balance gor.State[int64]
}
func (a *account) Deposit(ctx context.Context, amount int64) (int64, error) {
if amount <= 0 {
return 0, errors.New("amount must be positive")
}
v := a.balance.Get() + amount
if err := a.balance.Set(ctx, v); err != nil {
return 0, err
}
return v, nil
}
func (a *account) Balance(ctx context.Context) (int64, error) {
return a.balance.Get(), nil
}The interface, implementation, and registration live in the Grain package.
The factory uses the unexported account type, so the registration stays in
that package.
func Register(rt *gor.Runtime) error {
return gor.Register[Account](rt, func(g *gor.GrainContext) Account {
return &account{balance: gor.NewState[int64](g, "balance")}
})
}g is given by the Runtime. It connects State cells to the Store. Apart from
that, the factory is an ordinary constructor.
Method bodies need no lock for Call serialization. A second Call for the same Grain does not run at the same time.
The struct can also keep its GrainId:
type account struct {
id gor.GrainId
balance gor.State[int64]
}
func Register(rt *gor.Runtime) error {
return gor.Register[Account](rt, func(g *gor.GrainContext) Account {
return &account{
id: gor.Self(g),
balance: gor.NewState[int64](g, "balance"),
}
})
}The GrainId is useful for logs, business data, and Calls to another Grain.
The alice key can be a user name.
The GrainId is not State. It is not stored as Grain State. It does not change when the Grain leaves memory and starts again. Two Activations for one GrainId have the same GrainId.
The Grain Context is given to the factory once, during activation. If method bodies need it, keep it in the Grain. Use this registration shape:
type device struct {
grain *gor.GrainContext
reading gor.State[reading]
}
func Register(rt *gor.Runtime) error {
return gor.Register[Device](rt, func(g *gor.GrainContext) Device {
return &device{grain: g, reading: gor.NewState[reading](g, "reading")}
})
}
func (d *device) Report(ctx context.Context, value float64) error {
next := d.reading.Get()
next.ReportedAt = gor.Now(d.grain)
...
}Do not use time.Now(). Time read by a Grain must come from the Runtime.
Tests must control time, and a future Silo may have a different clock.
The same function as calling from outside, with a different first argument:
gor.Ref[Workshop](d.grain, workshopID).DeviceOnline(ctx, deviceID)Outside, the caller holds the Runtime. Inside, the Grain holds its Grain
Context. The factory needs only func(g *gor.GrainContext) T.
Cross-Grain Calls are part of the virtual Grain model. They use the same typed reference as a local Call.
acct := gor.Ref[Account](rt, "alice")
balance, err := acct.Deposit(ctx, 100)acct has type Account. A wrong argument type or a missing method is a
compile error. This is the key difference from any-based APIs. See
../design/codegen.md.
This section is not part of the 0.1.0 product contract.
Call syntax does not change in a Cluster. These limits apply:
Branchable errors need stable error codes. errors.Is can check a declared
code across Silos. An undeclared error keeps only diagnostic text. See
errors.md.
Cancellation does not cross Silos. Local cancellation reaches the method context. A forwarded Call can continue on the target Silo. See errors.md.
Forwarded arguments and results use JSON. Their types must support JSON.
A Cluster needs compatible Application versions. An incompatible method or State change needs downtime or an Application migration path.
One Grain processes Calls in a queue. When the queue is full, new Calls are rejected for overload. The method does not start, and State does not change. The Silo also limits starting, active, and deactivating Activations. A Call that needs a new Activation can be rejected before the Grain lane, mailbox, and factory when that limit is full. Calls to an active Grain do not need a new Activation slot.
Timeout or cancellation means that the caller stopped waiting. The method may have started and may have changed State. A delivery error after a Call was sent has the same unknown result. The caller cannot know if the Business Action ran.
A method panic returns an error and discards the current Activation. Queued Calls that did not start also return errors. The Runtime does not replay them. The next Call builds a new Activation from confirmed State.
While a Grain handles one Call, it does not start a second Call. A Call cycle is detected and fails instead of waiting forever. The Runtime does not retry the Call. The Application decides whether a Safe Repeat is valid.
Calls from one caller to one Grain execute in local issue order. A future Cluster does not promise network arrival order.
gor.State[T] carries State. Get() reads the current value. Set() writes
and persists it.
Exists() tells whether confirmed State is present. It is different from
reading a present value that contains the type's zero value. Clear()
removes confirmed State. After Clear() succeeds, the next Activation sees
the State as absent.
A Grain can have several named State values. They are stored as one Grain record.
When State holds a map or slice, Get() returns that value, not a copy.
The same rule applies to pointers and interfaces that contain reference
values. Call Set() after you change such a value. The Runtime cannot undo a
change that the Application made through the returned value.
Each Grain State record has an ETag. A successful write changes its ETag.
Every Set() tries to persist immediately. Only success confirms the new
value, presence mark, and ETag. A failed write can have an Unknown
Result. A Conflict, cancellation, timeout, or lost reply all end the current
Activation. The next Call starts a new Activation and reads Confirmed State.
The Runtime does not retry the Business Action.
A persistence error has a stable error code. Its text names the State operation and the GrainId. An error for one named State also names that State.
Multiple Set() calls in one method are separate State writes. An earlier
write may succeed before a later write fails. Keep one business change in one
State update when that result is required.
State must be JSON-encodable. The Runtime stores all named State values for a
Grain in one JSON object. The State names are the object keys. The Application
owns State format changes and must read old data when its format changes.
An empty, null, or malformed State record fails Activation with a stable
persistence error. The Runtime does not replace that record.
In the Cluster preview, the Runtime can have two Activations for one Grain while ownership changes. Both may accept a Call. The ETag check rejects the old write instead of silently replacing newer State. The caller receives a conflict and decides whether to retry.
This behavior follows the Orleans model. A Single Silo has no Ownership change, so this Conflict does not occur there.
A state change a call returns is confirmed. How much a confirmed change survives a crash is a setting you choose at startup.
Two levels:
- Full — the default. Every confirmed change is already on disk when the call returns. If the machine loses power or the operating system crashes, you lose nothing that was confirmed.
- Relaxed. Confirmed changes are not forced to disk one at a time. A normal restart — the process exits and comes back — loses nothing. A power loss or an operating-system crash can lose the most recent changes; what is already on disk stays intact and readable, never corrupted.
The trade is throughput. Forcing every write to disk costs time; most services can tolerate losing the most recent changes after a hard crash, and Relaxed lets those services change state faster.
Relaxed touches Grain State and nothing else. Reminders still fire at most once after a crash. Future Cluster Ownership data is unaffected.
If you do not choose, you get Full. The mechanism behind the trade and its exact limits are in the persistence design.
The built-in SQLite Store uses two database files. For data/gor.db, the
coordination file is data/gor.db. The State file is data/gor-state.db.
Each file can also have a -wal file.
Use this cold backup procedure:
- Call
Shutdown(ctx)and wait for it to return. - Close the SQLite Store.
- Copy both database files as one backup set.
- Copy each
-walfile that still exists.
For restore, replace the complete closed file set. Do not restore only one database file. The next Runtime start checks both databases and rejects a damaged backup.
State connects to the store through gor.State[T]. A Reminder uses Grain
Context in the same way:
//gor:grain
type InterestAccount interface {
Open(ctx context.Context) error
ApplyInterest(ctx context.Context, tick gor.TickStatus) error
}
type interestAccount struct {
balance gor.State[int64]
reminder gor.Reminder[InterestAccount]
}
func RegisterInterestAccount(rt *gor.Runtime) error {
return gor.Register[InterestAccount](rt, func(g *gor.GrainContext) InterestAccount {
return &interestAccount{
balance: gor.NewState[int64](g, "balance"),
reminder: gor.NewReminder[InterestAccount](g),
}
})
}
func (a *interestAccount) Open(ctx context.Context) error {
schedule := gor.Every(30 * 24 * time.Hour)
return a.reminder.Set(ctx, "monthly-interest", schedule, gor.Handle(InterestAccount.ApplyInterest))
}
func (a *interestAccount) ApplyInterest(ctx context.Context, _ gor.TickStatus) error {
balance := a.balance.Get()
return a.balance.Set(ctx, balance+(balance/100))
}
account := gor.Ref[InterestAccount](rt, "alice")
if err := account.Open(ctx); err != nil {
return err
}Open is an ordinary Call. The Application calls it to set the Reminder. The
Runtime does not call methods only because they have a specific name.
A Reminder is persistent. After a process crash, a due Reminder can still run. If the Grain is not in memory, the Runtime starts its Activation.
The Reminder is typed to the Grain interface. The Reminder method uses a
method expression, so a typo or rename is a compile error. The Runtime stores
the method name, not a function value. The method takes ctx and
gor.TickStatus, and returns error. TickStatus.ReminderName is the
persisted Reminder name that caused the Call. One method can use it to handle
many dynamic Reminder names.
An old persisted Reminder can contain a GrainType or method that is not
installed. This is an Invalid Reminder. The Runtime gives the unchanged
setting a Terminal Result with an ETag Claim. This removes the setting. The
Runtime then reports one ReminderDispatch error. Later polls and a process
restart do not report the same setting again. Setting the same Reminder name
again creates a new setting.
Set rejects an empty name, a negative due time, a negative period, and an
invalid method handle. It does this before Store I/O. Every(0) creates a
one-shot Reminder.
It is not time.AfterFunc. It does not promise millisecond precision. It
does not replay every tick missed during downtime.
One Grain has at most one Reminder with a given name. Setting the same name again changes that Reminder.
A Reminder can be one-shot or periodic. Cancellation removes it. A one-shot Reminder is delivered at most once when due.
A Reminder promises at-most-once delivery. It does not promise exactly-once method execution. The Runtime claims the due time before delivery. A crash between these actions can miss the Call. A failed method is not retried; its error goes to the background error sink.
The Runtime reads due Reminders in cursor pages. It also limits active Reminder
Calls. The defaults are 256 rows per page and 16 active Calls. Use
WithReminderPageSize and WithReminderWorkers to set other positive values.
A State change and a Reminder change are separate Runtime actions. The Application must handle a partial result when both actions are needed.
A Grain can initialize when its Activation starts. If initialization fails, that Call fails and the next Call builds a new Activation.
A Grain can request Deactivate on Idle. The Runtime ends the Activation after
the current Call. Use gor.DeactivateOnIdle(grainContext) in a Grain method.
Calls that already wait keep their order and enter a new Activation. A panic
in the current Call faults the old Activation instead. In that case, waiting
Calls fail and do not run.
A Grain can run a deactivation hook before it leaves. The hook receives the reason: idle, Application request, ownership lost, Runtime shutdown, or an untrusted Activation.
The hook cannot prevent deactivation. A graceful stop waits for a hook that has started. An abrupt stop does not start new hooks. A hook that has started is not force-aborted.
A Grain Timer runs a callback for the current Activation. It is not saved. The Runtime stops it when that Activation ends.
func (g *cacheGrain) startRefreshTimer(grainContext *gor.GrainContext) error {
timer, err := gor.RegisterGrainTimer(grainContext, func(ctx context.Context) error {
return g.refreshLocalCache(ctx)
}, gor.GrainTimerOptions{
DueTime: time.Second,
Period: time.Minute,
})
if err != nil {
return err
}
g.refreshTimer = timer
return nil
}Keep the returned handle when the Grain must call Change or Stop.
The callback enters the same mailbox as a Call. It does not overlap with a Call or another callback for that Grain. A repeating period starts after the callback finishes. The Grain can change or stop the timer.
Stop also prevents a queued callback from starting. It does not cancel a
callback that already runs. Change after Stop or Deactivation returns
gor.ErrGrainTimerStopped. Timer callbacks use a Runtime context. They do not
inherit Request Context or the Call deadline that created the Activation. A
callback that calls the same Grain gets gor.ErrCallCycle.
A Grain Timer does not keep the Activation active by default. The Application can select keep-alive when it registers the timer. A completed keep-alive callback resets idle time. It does not keep the Activation forever. A long period can still let the Activation end before the next callback. Use a Reminder when work must survive deactivation or a process restart.
Background work can fail with no caller waiting. Grain Timer callbacks, Reminder deliveries, and deactivation hooks send these failures to one background error sink.
Each event gives the GrainId when known, the original error, and a source.
ReminderScan reports a Store scan failure. ReminderDispatch reports an
unknown stored GrainType or method. ReminderTerminal reports a Store failure
while the Runtime claims an Invalid Reminder for its Terminal Result.
ReminderClaim reports a Claim Store failure. ReminderInvocation gives the
Reminder name, method, and TickStatus. A deactivation event gives the reason.
A lost Reminder Claim CAS is normal contention. It is not an error event. A
Claim can take effect before its Store reports an error. In this case, the
Runtime reports ReminderClaim and does not deliver that due time.
Errors follow Errors and cancellation. Across Silos, only declared stable codes support business decisions. Error text is diagnostic.
The sink does not retry, back off, or alert. Reminder delivery is at-most-once by design. The Application owns any Safe Repeat behavior.
The handler must return promptly. It must not do blocking I/O.
The handler must read the Reminder source or deactivation reason from the event source. It must not infer the source from a method name.
The Runtime provides two kinds of facts. First, it provides a snapshot of this Silo's active Activations and their queued Calls. It does not aggregate data for a future Cluster.
Second, it provides one event for each completed Call. The event gives the caller result, duration, GrainType, and method. A canceled Call has one canceled result even if the method later completes.
Completion callbacks run with the caller. A callback must not block or do I/O. The Runtime does not aggregate, export, or alert these events.
Single Silo, State in a local file:
if err := os.MkdirAll("data", 0o755); err != nil { return err }
database, err := store.OpenSQLite("data/gor.db")
if err != nil { return err }
defer database.Close()
rt, err := gor.New(
gor.WithStore(database),
gor.WithReminderStore(database),
)
if err != nil { return err }
if err := gorgen.Install(rt); err != nil { return err }
if err := domain.Register(rt); err != nil { return err }
if err := rt.Start(ctx); err != nil { return err }
// Run Application Calls.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := rt.Shutdown(shutdownCtx); err != nil { return err }
return nilInstall gives generated Grain Reference and dispatch functions to the Runtime.
Without this line, Grain registration and Grain References fail at startup.
Start checks and freezes the complete setup before it accepts a Call or
claims a Reminder. The Application calls Shutdown(ctx) before it closes the
database.
The 0.1.0 product uses one Silo. Cluster configuration is not part of this startup contract. GrainId and Call boundaries leave room for later Cluster work. A Single Silo does not configure a Transport or membership Store.
The Application uses Shutdown(ctx) for a normal stop. Shutdown stops new
Call admission and waits for Runtime infrastructure to end. If the context
ends first, Shutdown uses the abrupt stop path and returns the context error.
In the Cluster preview, other Silos can declare a Silo dead. That Silo must then serve no Grain.
The Runtime provides two signals:
<-rt.Stopping() // Call admission has ended.
<-rt.Done() // Runtime infrastructure has ended.Stopping also closes when a Cluster declares this Silo dead. Calls issued
after it closes get a stable stop error. Codes and checks are in
errors.md.
Stopping does not rewrite results for Calls already admitted. A
graceful stop lets started methods finish and rejects queued Calls. An abrupt
stop cancels started methods but cannot force-abort user code that ignores
cancellation.
The process should exit or build a new Runtime and rejoin. It must not keep advertising the stopped Runtime.
The Runtime setup and stop state machines are implemented. Start freezes
setup before it accepts a Call. Shutdown uses its context as the stop budget.
Stopping and Done report the two stop transitions shown above.
The device shadow example combines Grain References, State, Reminders, lifecycle hooks, and HTTP Calls.