Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 54 additions & 20 deletions state.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Binder struct {
type stateCell interface {
encode() ([]byte, error)
decode([]byte) error
isPresent() bool
}

func newBinder(runtime *Runtime, id GrainId) *Binder {
Expand All @@ -39,9 +40,9 @@ func Self(b *Binder) GrainId {
}

// NewState registers a named persistent value for the entity bound to b and
// returns its handle. The name must be unique within that entity; registering
// the same name twice panics. A newly registered state has its type's zero
// value until activation data is loaded or Set succeeds.
// returns its handle. The name must be unique within that Grain; registering
// the same name twice panics. A newly registered State is absent and has its
// type's zero value until activation data is loaded or Set succeeds.
func NewState[T any](b *Binder, name string) State[T] {
if _, exists := b.states[name]; exists {
panic(fmt.Sprintf("state %q is already registered", name))
Expand All @@ -51,10 +52,10 @@ func NewState[T any](b *Binder, name string) State[T] {
return State[T]{cell: cell}
}

// State is a handle to one named JSON-encoded value in an entity's persistent
// state record. All State handles for one entity share one JSON object, one
// store record, and one ETag; setting one handle rewrites the complete record.
// Obtain a State with NewState; the zero value is not usable.
// State is a handle to one named JSON-encoded value in a Grain's persistent
// state record. All State handles for one Grain share one JSON object, one
// store record, and one ETag; setting or clearing one handle rewrites the
// complete record. Obtain a State with NewState; the zero value is not usable.
type State[T any] struct {
cell *stateCellValue[T]
}
Expand All @@ -67,7 +68,26 @@ func (s State[T]) Get() T {
return s.cell.value
}

// Set JSON-encodes value and persists the entity's complete state record using
// Exists reports whether this named State has a confirmed value. It does not
// compare the value with T's zero value.
func (s State[T]) Exists() bool {
return s.cell.isPresent()
}

// Clear removes this named State from the confirmed record and resets Get to
// T's zero value after the write succeeds. The write uses the current Grain
// ETag and has the same conflict and store failure behavior as Set.
func (s State[T]) Clear(ctx context.Context) error {
if err := s.cell.binder.persist(ctx, s.cell, nil, false); err != nil {
return err
}
var zero T
s.cell.value = zero
s.cell.present = false
return nil
}

// Set JSON-encodes value and persists the Grain's complete state record using
// ctx. A JSON encoding error for value or another registered state leaves the
// current value unchanged and is returned without a store write. Store errors
// leave the current in-memory value unchanged, but do not establish whether the
Expand All @@ -83,24 +103,34 @@ func (s State[T]) Set(ctx context.Context, value T) error {
if err != nil {
return err
}
if err := s.cell.binder.persist(ctx, s.cell, encoded); err != nil {
if err := s.cell.binder.persist(ctx, s.cell, encoded, true); err != nil {
return err
}
s.cell.value = value
s.cell.present = true
return nil
}

type stateCellValue[T any] struct {
binder *Binder
value T
binder *Binder
value T
present bool
}

func (s *stateCellValue[T]) encode() ([]byte, error) {
return json.Marshal(s.value)
}

func (s *stateCellValue[T]) decode(data []byte) error {
return json.Unmarshal(data, &s.value)
if err := json.Unmarshal(data, &s.value); err != nil {
return err
}
s.present = true
return nil
}

func (s *stateCellValue[T]) isPresent() bool {
return s.present
}

func (b *Binder) load(ctx context.Context) error {
Expand Down Expand Up @@ -133,18 +163,22 @@ func (b *Binder) load(ctx context.Context) error {
return nil
}

func (b *Binder) persist(ctx context.Context, changed stateCell, changedData []byte) error {
func (b *Binder) persist(ctx context.Context, changed stateCell, changedData []byte, changedPresent bool) error {
document := make(map[string]json.RawMessage, len(b.states))
for name, cell := range b.states {
var data []byte
if cell == changed {
data = changedData
} else {
var err error
data, err = cell.encode()
if err != nil {
return err
if !changedPresent {
continue
}
document[name] = json.RawMessage(changedData)
continue
}
if !cell.isPresent() {
continue
}
data, err := cell.encode()
if err != nil {
return err
}
document[name] = json.RawMessage(data)
}
Expand Down
218 changes: 215 additions & 3 deletions state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gor
import (
"context"
"errors"
"path/filepath"
"testing"

"github.com/suraciii/gor/clock"
Expand Down Expand Up @@ -124,11 +125,12 @@ func TestState_WriteErrorLeavesValueAndMarksBinder(t *testing.T) {
}

type failingWriteStore struct {
err error
err error
record store.Record
}

func (failingWriteStore) Read(context.Context, store.GrainId) (store.Record, error) {
return store.Record{}, nil
func (f failingWriteStore) Read(context.Context, store.GrainId) (store.Record, error) {
return f.record, nil
}

func (s failingWriteStore) Write(context.Context, store.GrainId, []byte, store.ETag) (store.ETag, error) {
Expand All @@ -146,3 +148,213 @@ func TestNewState_PanicsOnDuplicateName(t *testing.T) {
}()
NewState[string](binder, "balance")
}

func TestState_NewValueIsAbsentAndNotPersisted(t *testing.T) {
forEachStateBackend(t, func(t *testing.T, backend store.Store) {
binder := newTestBinder(GrainId{GrainType: "account", GrainKey: "alice"}, backend, nil, clock.Real{})
absent := NewState[int](binder, "absent")
other := NewState[int](binder, "other")

if absent.Exists() {
t.Fatal("new State Exists = true, want false")
}
if absent.Get() != 0 {
t.Fatalf("new State Get = %d, want zero", absent.Get())
}
if err := other.Set(context.Background(), 7); err != nil {
t.Fatalf("other Set: %v", err)
}

record, err := backend.Read(context.Background(), binder.identity)
if err != nil {
t.Fatalf("Read: %v", err)
}
if string(record.Data) != `{"other":7}` {
t.Fatalf("stored data = %s, want only the present named value", record.Data)
}
})
}

func TestState_PresentZeroValueIsDistinctFromAbsent(t *testing.T) {
forEachStateBackend(t, func(t *testing.T, backend store.Store) {
id := GrainId{GrainType: "account", GrainKey: "alice"}
binder := newTestBinder(id, backend, nil, clock.Real{})
value := NewState[int](binder, "value")

if err := value.Set(context.Background(), 0); err != nil {
t.Fatalf("Set zero: %v", err)
}
if !value.Exists() {
t.Fatal("present zero State Exists = false, want true")
}
if value.Get() != 0 {
t.Fatalf("present zero State Get = %d, want zero", value.Get())
}

restarted := newTestBinder(id, backend, nil, clock.Real{})
restartedValue := NewState[int](restarted, "value")
if err := restarted.load(context.Background()); err != nil {
t.Fatalf("load: %v", err)
}
if !restartedValue.Exists() || restartedValue.Get() != 0 {
t.Fatalf("restarted zero State = (exists=%v, value=%d), want (true, 0)", restartedValue.Exists(), restartedValue.Get())
}
})
}

func TestState_ClearKeepsEmptyRecordAndAbsenceAfterRestart(t *testing.T) {
forEachStateBackend(t, func(t *testing.T, backend store.Store) {
id := GrainId{GrainType: "account", GrainKey: "alice"}
binder := newTestBinder(id, backend, nil, clock.Real{})
value := NewState[int](binder, "value")
if err := value.Set(context.Background(), 9); err != nil {
t.Fatalf("Set: %v", err)
}
if err := value.Clear(context.Background()); err != nil {
t.Fatalf("Clear: %v", err)
}
if value.Exists() {
t.Fatal("cleared State Exists = true, want false")
}
if value.Get() != 0 {
t.Fatalf("cleared State Get = %d, want zero", value.Get())
}

record, err := backend.Read(context.Background(), store.GrainId(id))
if err != nil {
t.Fatalf("Read after Clear: %v", err)
}
if string(record.Data) != `{}` || record.ETag != 2 {
t.Fatalf("record after Clear = %#v, want empty record with ETag 2", record)
}

restarted := newTestBinder(id, backend, nil, clock.Real{})
restartedValue := NewState[int](restarted, "value")
if err := restarted.load(context.Background()); err != nil {
t.Fatalf("restart load: %v", err)
}
if restartedValue.Exists() || restartedValue.Get() != 0 {
t.Fatalf("restarted cleared State = (exists=%v, value=%d), want (false, 0)", restartedValue.Exists(), restartedValue.Get())
}
})
}

func TestState_ClearPreservesOtherPresentValues(t *testing.T) {
forEachStateBackend(t, func(t *testing.T, backend store.Store) {
binder := newTestBinder(GrainId{GrainType: "account", GrainKey: "alice"}, backend, nil, clock.Real{})
first := NewState[int](binder, "first")
second := NewState[string](binder, "second")
if err := first.Set(context.Background(), 1); err != nil {
t.Fatalf("first Set: %v", err)
}
if err := second.Set(context.Background(), "two"); err != nil {
t.Fatalf("second Set: %v", err)
}
if err := first.Clear(context.Background()); err != nil {
t.Fatalf("first Clear: %v", err)
}
if err := second.Set(context.Background(), "updated"); err != nil {
t.Fatalf("second update: %v", err)
}
if first.Exists() {
t.Fatal("cleared first State Exists = true, want false")
}
if !second.Exists() || second.Get() != "updated" {
t.Fatalf("second State = (exists=%v, value=%q), want (true, updated)", second.Exists(), second.Get())
}

record, err := backend.Read(context.Background(), binder.identity)
if err != nil {
t.Fatalf("Read: %v", err)
}
if string(record.Data) != `{"second":"updated"}` {
t.Fatalf("stored data after preserving other value = %s, want only second", record.Data)
}
})
}

func TestState_ClearConflictLeavesPresenceAndValueUnchanged(t *testing.T) {
forEachStateBackend(t, func(t *testing.T, backend store.Store) {
id := GrainId{GrainType: "account", GrainKey: "alice"}
first := newTestBinder(id, backend, nil, clock.Real{})
firstValue := NewState[int](first, "value")
if err := firstValue.Set(context.Background(), 1); err != nil {
t.Fatalf("first Set: %v", err)
}

second := newTestBinder(id, backend, nil, clock.Real{})
secondValue := NewState[int](second, "value")
if err := second.load(context.Background()); err != nil {
t.Fatalf("second load: %v", err)
}
record, err := backend.Read(context.Background(), store.GrainId(id))
if err != nil {
t.Fatalf("Read before external update: %v", err)
}
if _, err := backend.Write(context.Background(), store.GrainId(id), []byte(`{"value":2}`), record.ETag); err != nil {
t.Fatalf("external Write: %v", err)
}

err = secondValue.Clear(context.Background())
if !errors.Is(err, store.ErrConflict) {
t.Fatalf("Clear error = %v, want store.ErrConflict", err)
}
if !secondValue.Exists() || secondValue.Get() != 1 {
t.Fatalf("State after conflict = (exists=%v, value=%d), want (true, 1)", secondValue.Exists(), secondValue.Get())
}
if !errors.Is(second.discardError(), store.ErrConflict) {
t.Fatalf("discard marker = %v, want store.ErrConflict", second.discardError())
}
})
}

func TestState_ClearStoreFailureLeavesPresenceAndValueUnchanged(t *testing.T) {
writeErr := errors.New("store unavailable")
binder := newTestBinder(
GrainId{GrainType: "account", GrainKey: "alice"},
failingWriteStore{err: writeErr, record: store.Record{Data: []byte(`{"value":1}`), ETag: 1}},
nil,
clock.Real{},
)
value := NewState[int](binder, "value")
if err := binder.load(context.Background()); err != nil {
t.Fatalf("load: %v", err)
}

err := value.Clear(context.Background())
if !errors.Is(err, writeErr) {
t.Fatalf("Clear error = %v, want %v", err, writeErr)
}
if !value.Exists() || value.Get() != 1 {
t.Fatalf("State after store failure = (exists=%v, value=%d), want (true, 1)", value.Exists(), value.Get())
}
if !errors.Is(binder.discardError(), writeErr) {
t.Fatalf("discard marker = %v, want %v", binder.discardError(), writeErr)
}
}

func forEachStateBackend(t *testing.T, test func(*testing.T, store.Store)) {
t.Helper()
for _, backend := range []struct {
name string
open func(*testing.T) store.Store
}{
{name: "memory", open: func(*testing.T) store.Store { return store.NewMemory() }},
{name: "sqlite", open: func(t *testing.T) store.Store {
sqliteStore, err := store.OpenSQLite(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
t.Cleanup(func() {
if err := sqliteStore.Close(); err != nil {
t.Errorf("Close SQLite: %v", err)
}
})
return sqliteStore
}},
} {
t.Run(backend.name, func(t *testing.T) {
test(t, backend.open(t))
})
}
}
Loading