From 434f5dc61668a6051d39dfc13dce20f41b139158 Mon Sep 17 00:00:00 2001 From: Tequila Sunset Date: Sat, 8 Aug 2026 18:38:58 +0800 Subject: [PATCH] Add State presence and clear --- state.go | 74 ++++++++++++----- state_test.go | 218 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 269 insertions(+), 23 deletions(-) diff --git a/state.go b/state.go index f6b4f00..537e3de 100644 --- a/state.go +++ b/state.go @@ -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 { @@ -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)) @@ -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] } @@ -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 @@ -83,16 +103,18 @@ 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) { @@ -100,7 +122,15 @@ func (s *stateCellValue[T]) encode() ([]byte, error) { } 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 { @@ -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) } diff --git a/state_test.go b/state_test.go index f5ec1c6..dbcf688 100644 --- a/state_test.go +++ b/state_test.go @@ -3,6 +3,7 @@ package gor import ( "context" "errors" + "path/filepath" "testing" "github.com/suraciii/gor/clock" @@ -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) { @@ -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)) + }) + } +}