Skip to content

Latest commit

 

History

History
565 lines (421 loc) · 21.1 KB

File metadata and controls

565 lines (421 loc) · 21.1 KB

Programming model

This document describes the target API. Implementation progress: ../ROADMAP.md.

Core concepts

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.

Declaring a Grain

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.

Generation prerequisite

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/gorgen

Keep 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 Grain knows its GrainId

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 reads time

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.

One Grain calls another

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.

Calling a Grain

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.

Cluster preview: Calls and deployment limits

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.

Call outcomes and ordering

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.

State

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.

How durable a state write is

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.

SQLite cold backup and restore

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:

  1. Call Shutdown(ctx) and wait for it to return.
  2. Close the SQLite Store.
  3. Copy both database files as one backup set.
  4. Copy each -wal file 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.

Reminder

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.

How a Grain starts and leaves

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.

Grain Timers

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.

Failures nobody is waiting for

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.

Runtime observability

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.

Runtime startup

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 nil

Install 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 runtime can stop itself

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.

Complete example

The device shadow example combines Grain References, State, Reminders, lifecycle hooks, and HTTP Calls.