package hostactors import ( "context" "errors" "sync" "testing" "time" "github.com/carlosframework/platform/internal/release" ) // dsClock is a clock the store underneath and the test both move. type dsClock struct { mu sync.Mutex t time.Time } func (c *dsClock) now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.t } func (c *dsClock) add(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.t = c.t.Add(d) } // dsStore is a store underneath the bound: put runs in place of each Put, // and every call it receives is counted. type dsStore struct { release.Store mu sync.Mutex calls int put func(ctx context.Context) (string, error) } func (s *dsStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { s.mu.Lock() s.calls++ s.mu.Unlock() return s.put(ctx) } func (s *dsStore) count() int { s.mu.Lock() defer s.mu.Unlock() return s.calls } // No call is issued at or after the deadline, by the read's clock, and the // store underneath never sees it. func TestDeadlineStoreIssuesNothingAtTheDeadline(t *testing.T) { clock := &dsClock{t: time.Now()} inner := &dsStore{put: func(context.Context) (string, error) { return "e1", nil }} bd, bctx, cancel := writeBound(context.Background(), Directory{Store: inner}, membershipConfirmation{observedAt: clock.now()}, clock.now) defer cancel() clock.add(membershipFreshness) if _, err := bd.Store.Put(bctx, Key, []byte("x"), "e0"); !errors.Is(err, ErrWriteDeadline) || inner.count() != 0 { t.Fatalf("a Put at the deadline: %v, %d calls reached the store", err, inner.count()) } } // A store that ignores its context is cut off by the wrapper at // storeCallTimeout, and the context it was given is cancelled: the outcome // is unknown. func TestDeadlineStoreCutsOffAStoreThatNeverAnswers(t *testing.T) { old := storeCallTimeout storeCallTimeout = 50 * time.Millisecond defer func() { storeCallTimeout = old }() unblock := make(chan struct{}) defer close(unblock) given := make(chan context.Context, 1) inner := &dsStore{put: func(ctx context.Context) (string, error) { given <- ctx <-unblock return "e1", nil }} now := time.Now bd, bctx, cancel := writeBound(context.Background(), Directory{Store: inner}, membershipConfirmation{observedAt: now()}, now) defer cancel() start := time.Now() _, err := bd.Store.Put(bctx, Key, []byte("x"), "e0") took := time.Since(start) if !errors.Is(err, ErrWriteDeadline) || took > 5*time.Second { t.Fatalf("a store that never answers: %v after %s", err, took) } ctx := <-given if dl, ok := ctx.Deadline(); !ok || dl.Sub(start) > storeCallTimeout+time.Second { t.Fatalf("the call's context had deadline %v (%v), want within %s of the call", dl, ok, storeCallTimeout) } if ctx.Err() == nil { t.Fatal("the call's context was not cancelled when the wrapper gave up") } } // A success that answers only after the deadline is unknown, never a // success. func TestDeadlineStoreCountsALateSuccessAsUnknown(t *testing.T) { clock := &dsClock{t: time.Now()} inner := &dsStore{} inner.put = func(context.Context) (string, error) { clock.add(membershipFreshness + time.Second) return "e1", nil } bd, bctx, cancel := writeBound(context.Background(), Directory{Store: inner}, membershipConfirmation{observedAt: clock.now()}, clock.now) defer cancel() if etag, err := bd.Store.Put(bctx, Key, []byte("x"), "e0"); !errors.Is(err, ErrWriteDeadline) || etag != "" || inner.count() != 1 { t.Fatalf("a late success: %q %v, %d calls", etag, err, inner.count()) } } // An unknown read, or a write the bound could not confirm, is never one of // the answers that count against the attempt, and leaves the attempt as it // was: same ids, not consumed, the roster unwritten. func TestAnUnknownOutcomeKeepsTheAttempt(t *testing.T) { check := func(t *testing.T, w *memberWorld, err error, want error) { t.Helper() if !errors.Is(err, want) || errors.Is(err, ErrNotMember) || errors.Is(err, ErrMembershipStale) { t.Fatalf("got %v, want only %v", err, want) } o, rerr := w.d.Read(context.Background()) if rerr != nil { t.Fatal(rerr) } a := o.Roster.EnrollmentAttempts[newRef] if o.Roster.Revision != w.o.Roster.Revision || a.Consumed || a.AttemptID != w.in.AttemptID || a.ConfirmationID != w.in.ConfirmationID { t.Fatalf("the attempt moved: revision %d (was %d), %+v", o.Roster.Revision, w.o.Roster.Revision, a) } } t.Run("the read fails", func(t *testing.T) { w := newMemberWorld(t, member("routers-ie")) w.cloud.err = errors.New("RequestTimeout") _, ev, err := w.enroll() check(t, w, err, ErrMembershipUnknown) if len(ev.RequestIDs) != 0 { t.Fatalf("a failed read carried request ids %v", ev.RequestIDs) } }) t.Run("the write is cut off", func(t *testing.T) { w := newMemberWorld(t, member("routers-ie")) clock := &dsClock{t: w.clock} inner := w.d.Store w.d.Store = &dsStore{Store: inner, put: func(context.Context) (string, error) { // The write answers success, but only at the deadline. clock.add(membershipFreshness) return "e-late", nil }} _, _, err := w.d.EnrollAutomatic(context.Background(), w.sg, w.o, w.v, w.in, w.raw, "i-new", w.cloud, clock.now) w.d.Store = inner check(t, w, err, ErrWriteDeadline) }) }