diff --git a/injector.go b/injector.go index 5fa3fe8..8802e64 100644 --- a/injector.go +++ b/injector.go @@ -57,6 +57,12 @@ type Injector interface { // ShutdownWithContext gracefully shuts down the injector and all its descendant scopes with context support. ShutdownWithContext(context.Context) *ShutdownReport + // Delete gracefully shuts down the injector scope and removes it from its parent when applicable. + Delete() *ShutdownReport + + // DeleteWithContext gracefully shuts down the injector scope with context support and removes it from its parent when applicable. + DeleteWithContext(context.Context) *ShutdownReport + // clone creates a deep copy of the injector with all its services and child scopes. clone(*RootScope, *Scope) *Scope diff --git a/root_scope.go b/root_scope.go index 268105c..65f94dd 100644 --- a/root_scope.go +++ b/root_scope.go @@ -184,6 +184,23 @@ func (s *RootScope) ShutdownWithContext(ctx context.Context) *ShutdownReport { return s.self.ShutdownWithContext(ctx) } +// Delete gracefully shuts down the root scope and removes it from the hierarchy. +// Since the root scope has no parent, this behaves like Shutdown. +func (s *RootScope) Delete() *ShutdownReport { return s.DeleteWithContext(context.Background()) } + +// DeleteWithContext gracefully shuts down the root scope with context support and removes it from the hierarchy. +// This is equivalent to ShutdownWithContext but keeps API parity with Scope. +func (s *RootScope) DeleteWithContext(ctx context.Context) *ShutdownReport { + defer func() { + if s.healthCheckPool != nil { + s.healthCheckPool.stop() + s.healthCheckPool = nil + } + }() + + return s.self.DeleteWithContext(ctx) +} + func (s *RootScope) clone(root *RootScope, parent *Scope) *Scope { return s.self.clone(root, parent) } func (s *RootScope) serviceExist(name string) bool { return s.self.serviceExist(name) } func (s *RootScope) serviceExistRec(name string) bool { return s.self.serviceExistRec(name) } diff --git a/scope.go b/scope.go index 84aaa83..d728940 100644 --- a/scope.go +++ b/scope.go @@ -50,7 +50,7 @@ type Scope struct { name string // Human-readable name for the scope (immutable) rootScope *RootScope // Reference to the root scope (immutable) parentScope *Scope // Reference to the immediate parent scope (immutable) - childScopes map[string]*Scope // Map of child scopes (append only) + childScopes map[string]*Scope // Map of child scopes (managed under lock) mu sync.RWMutex // Mutex for thread-safe operations services map[string]any // Map of registered services @@ -368,6 +368,40 @@ func (s *Scope) ShutdownWithContext(ctx context.Context) *ShutdownReport { return report } +// Delete gracefully shuts down the scope (including all descendants) and removes it from its parent scope. +// When called on the root scope, this behaves like ShutdownWithContext. +func (s *Scope) Delete() *ShutdownReport { + return s.DeleteWithContext(context.Background()) +} + +// DeleteWithContext gracefully shuts down the scope with context support and removes it from its parent scope. +// It ensures the scope hierarchy stays consistent after deletion. +func (s *Scope) DeleteWithContext(ctx context.Context) *ShutdownReport { + s.logf("requested delete") + + parent := s.parentScope + + report := s.ShutdownWithContext(ctx) + + if parent != nil { + parent.mu.Lock() + for name, scope := range parent.childScopes { + if scope == s { + delete(parent.childScopes, name) + break + } + } + parent.mu.Unlock() + s.logf("delete completed; detached from parent `%s`", parent.name) + } else if s.rootScope != nil && s.rootScope.self == s { + s.logf("delete completed on root scope") + } else { + s.logf("delete completed on scope without parent") + } + + return report +} + // shutdownChildrenInParallel runs a parallel shutdown of children scopes. // This method shuts down all child scopes concurrently and then removes them // from the scope hierarchy. diff --git a/scope_test.go b/scope_test.go index 3065c48..106f6b2 100644 --- a/scope_test.go +++ b/scope_test.go @@ -95,6 +95,37 @@ func TestScope_Scope(t *testing.T) { is.Equal(0, child2.orderedInvocationIndex) } +func TestScope_Delete(t *testing.T) { + t.Parallel() + testWithTimeout(t, 300*time.Millisecond) + is := assert.New(t) + + root := New() + child := root.Scope("child") + grandChild := child.Scope("grandchild") + + ProvideNamedValue(child, "child-value", 42) + ProvideNamedValue(grandChild, "grandchild-value", "hello") + + val, err := InvokeNamed[int](child, "child-value") + is.NoError(err) + is.Equal(42, val) + + report := child.Delete() + is.True(report.Succeed) + + _, ok := root.ChildByName("child") + is.False(ok) + + _, err = InvokeNamed[int](child, "child-value") + is.Error(err) + _, err = InvokeNamed[string](grandChild, "grandchild-value") + is.Error(err) + + child2 := root.Scope("child") + is.NotNil(child2) +} + func TestScope_Scope_race(t *testing.T) { testWithTimeout(t, 300*time.Millisecond) injector := New() diff --git a/virtual_scope.go b/virtual_scope.go index 7ca49c4..47f4bd0 100644 --- a/virtual_scope.go +++ b/virtual_scope.go @@ -63,6 +63,10 @@ func (s *virtualScope) Shutdown() *ShutdownReport { return s.self.Shutdown() } func (s *virtualScope) ShutdownWithContext(ctx context.Context) *ShutdownReport { return s.self.ShutdownWithContext(ctx) } +func (s *virtualScope) Delete() *ShutdownReport { return s.self.Delete() } +func (s *virtualScope) DeleteWithContext(ctx context.Context) *ShutdownReport { + return s.self.DeleteWithContext(ctx) +} func (s *virtualScope) clone(r *RootScope, p *Scope) *Scope { return s.self.clone(r, p) } func (s *virtualScope) serviceExist(name string) bool { return s.self.serviceExist(name) } func (s *virtualScope) serviceExistRec(name string) bool { return s.self.serviceExistRec(name) }