diff --git a/server/internal/api/apiv1/post_init_handlers.go b/server/internal/api/apiv1/post_init_handlers.go index 59d01858..ba5a97ff 100644 --- a/server/internal/api/apiv1/post_init_handlers.go +++ b/server/internal/api/apiv1/post_init_handlers.go @@ -475,7 +475,8 @@ func (s *PostInitHandlers) DeleteDatabase(ctx context.Context, req *api.DeleteDa return nil, ErrDatabaseNotModifiable } - prevState := db.State + // Use the raw stored state as the guard for this transition. + prevState := db.RawState err = s.dbSvc.UpdateDatabaseState(ctx, db.DatabaseID, prevState, database.DatabaseStateDeleting) if err != nil { return nil, apiErr(err) diff --git a/server/internal/database/database.go b/server/internal/database/database.go index d550fde8..c1c250ca 100644 --- a/server/internal/database/database.go +++ b/server/internal/database/database.go @@ -48,6 +48,11 @@ type Database struct { CreatedAt time.Time UpdatedAt time.Time State DatabaseState + // RawState is the state as persisted in storage, before any derived + // adjustments (e.g. degrading State based on instance health). Callers + // that need to perform a guarded transition against the stored state + // (e.g. a compare-and-swap) should use this instead of State. + RawState DatabaseState Spec *Spec Instances []*Instance ServiceInstances []*ServiceInstance @@ -71,13 +76,32 @@ func databaseToStored(d *Database) *StoredDatabase { } } +var degradedInstanceStates = ds.NewSet( + InstanceStateDegraded, + InstanceStateFailed, + InstanceStateUnknown, + InstanceStateStopped, +) + func storedToDatabase(d *StoredDatabase, storedSpec *StoredSpec, instances []*Instance, serviceInstances []*ServiceInstance) *Database { + state := d.State + + if state == DatabaseStateAvailable { + for _, instance := range instances { + if degradedInstanceStates.Has(instance.State) { + state = DatabaseStateDegraded + break + } + } + } + return &Database{ DatabaseID: d.DatabaseID, TenantID: d.TenantID, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, - State: d.State, + State: state, + RawState: d.State, Spec: storedSpec.Spec, Instances: instances, ServiceInstances: serviceInstances, @@ -95,7 +119,6 @@ func storedToDatabases(storedDbs []*StoredDatabase, storedSpecs []*StoredSpec, a for _, instance := range allInstances { instancesByID[instance.DatabaseID] = append(instancesByID[instance.DatabaseID], instance) } - serviceInstancesByID := make(map[string][]*ServiceInstance, len(allServiceInstances)) for _, serviceInstance := range allServiceInstances { serviceInstancesByID[serviceInstance.DatabaseID] = append(serviceInstancesByID[serviceInstance.DatabaseID], serviceInstance) diff --git a/server/internal/database/database_test.go b/server/internal/database/database_test.go new file mode 100644 index 00000000..f3c6e677 --- /dev/null +++ b/server/internal/database/database_test.go @@ -0,0 +1,54 @@ +package database + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStoredToDatabaseDegradesWhenInstanceUnavailable(t *testing.T) { + tests := []struct { + name string + instanceState InstanceState + wantState DatabaseState + }{ + {"available instance keeps database available", InstanceStateAvailable, DatabaseStateAvailable}, + {"degraded instance degrades database", InstanceStateDegraded, DatabaseStateDegraded}, + {"failed instance degrades database", InstanceStateFailed, DatabaseStateDegraded}, + {"unknown instance degrades database", InstanceStateUnknown, DatabaseStateDegraded}, + {"stopped instance degrades database", InstanceStateStopped, DatabaseStateDegraded}, + {"creating instance does not degrade database", InstanceStateCreating, DatabaseStateAvailable}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stored := &StoredDatabase{ + DatabaseID: "db1", + State: DatabaseStateAvailable, + } + storedSpec := &StoredSpec{} + instances := []*Instance{ + {InstanceID: "i1", DatabaseID: "db1", State: tt.instanceState}, + } + + db := storedToDatabase(stored, storedSpec, instances, nil) + + assert.Equal(t, tt.wantState, db.State) + }) + } +} + +func TestStoredToDatabaseDoesNotDegradeNonAvailableDatabase(t *testing.T) { + stored := &StoredDatabase{ + DatabaseID: "db1", + State: DatabaseStateCreating, + } + storedSpec := &StoredSpec{} + instances := []*Instance{ + {InstanceID: "i1", DatabaseID: "db1", State: InstanceStateFailed}, + } + + db := storedToDatabase(stored, storedSpec, instances, nil) + + assert.Equal(t, DatabaseStateCreating, db.State) +} diff --git a/server/internal/database/service.go b/server/internal/database/service.go index 29d10951..0335b542 100644 --- a/server/internal/database/service.go +++ b/server/internal/database/service.go @@ -77,6 +77,7 @@ func (s *Service) CreateDatabase(ctx context.Context, spec *Spec) (*Database, er CreatedAt: now, UpdatedAt: now, State: DatabaseStateCreating, + RawState: DatabaseStateCreating, Spec: spec, NotCreated: true, }