From 54eb8e1845b12747968d23d5b93c3d77b9585873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 24 Sep 2025 10:42:58 +0200 Subject: [PATCH 1/4] add lifecycle component traits and tests --- .../commons/di/LifeCycleComponentTest.scala | 81 +++++++++++++++++++ .../commons/di/LifeCycleComponents.scala | 20 +++++ 2 files changed, 101 insertions(+) create mode 100644 core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala create mode 100644 core/src/main/scala/com/avsystem/commons/di/LifeCycleComponents.scala diff --git a/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala b/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala new file mode 100644 index 000000000..ec3bad774 --- /dev/null +++ b/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala @@ -0,0 +1,81 @@ +package com.avsystem.commons +package di + +import monix.execution.atomic.{Atomic, AtomicBoolean, AtomicInt} +import org.scalatest.funsuite.AsyncFunSuite + +class LifeCycleComponentTest extends AsyncFunSuite with Components { + + object init { + val inits: AtomicInt = Atomic(0) + val component: Component[InitializingComponent] = singleton { + new InitializingComponent { + override def init(): Unit = inits += 1 + } + } + } + + object asyncInit { + val inits: AtomicInt = Atomic(0) + val component: Component[AsyncInitializingComponent] = singleton { + new AsyncInitializingComponent { + def init()(implicit ec: ExecutionContext): Future[Unit] = Future { + inits += 1 + }(ec) + } + } + } + + object disposable { + val destroyed: AtomicBoolean = Atomic(false) + val component: Component[DisposableComponent] = singleton { + new DisposableComponent { + def destroy(): Unit = destroyed.set(true) + } + } + } + + + object asyncDisposable { + val destroyed: AtomicBoolean = Atomic(false) + val component: Component[AsyncDisposableComponent] = singleton { + new AsyncDisposableComponent { + def destroy()(implicit ec: ExecutionContext): Future[Unit] = Future { + destroyed.set(true) + }(ec) + } + } + } + + test("InitializingComponent singleton initializes once and returns same instance")(for { + _ <- Future.unit + _ = assert(init.inits.get() == 0) + c1 <- init.component.init + _ = assert(init.inits.get() == 1) + c2 <- init.component.init + _ = assert(init.inits.get() == 1) + } yield assert(c1 eq c2)) + + test("AsyncInitializingComponent singleton initializes once and returns same instance")(for { + _ <- Future.unit + _ = assert(asyncInit.inits.get() == 0) + c1 <- asyncInit.component.init + _ = assert(asyncInit.inits.get() == 1) + c2 <- asyncInit.component.init + _ = assert(asyncInit.inits.get() == 1) + } yield assert(c1 eq c2)) + + test("DisposableComponent destroy triggers side effect") { + assert(!disposable.destroyed.get()) + disposable.component.destroy.map { _ => + assert(disposable.destroyed.get()) + } + } + + test("AsyncDisposableComponent destroy triggers side effect") { + assert(!asyncDisposable.destroyed.get()) + asyncDisposable.component.destroy.map { _ => + assert(asyncDisposable.destroyed.get()) + } + } +} \ No newline at end of file diff --git a/core/src/main/scala/com/avsystem/commons/di/LifeCycleComponents.scala b/core/src/main/scala/com/avsystem/commons/di/LifeCycleComponents.scala new file mode 100644 index 000000000..80559c5e6 --- /dev/null +++ b/core/src/main/scala/com/avsystem/commons/di/LifeCycleComponents.scala @@ -0,0 +1,20 @@ +package com.avsystem.commons +package di + +trait InitializingComponent { + def init(): Unit + final def initialized(): this.type = init().thenReturn(this) +} + +trait DisposableComponent { + def destroy(): Unit +} + +trait AsyncInitializingComponent { + def init()(implicit ec: ExecutionContext): Future[Unit] + final def initialized()(implicit ec: ExecutionContext): Future[this.type] = init()(using ec).map[this.type](_ => this)(using ec) +} + +trait AsyncDisposableComponent { + def destroy()(implicit ec: ExecutionContext): Future[Unit] +} From 3456a4cbcbb1c54c0841c3b97100f4ff3cb45748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 24 Sep 2025 13:05:52 +0200 Subject: [PATCH 2/4] refactor lifecycle components to improve initialization and disposal handling --- .../commons/di/LifeCycleComponentTest.scala | 48 +++++++++++-------- .../com/avsystem/commons/di/Component.scala | 33 +++++++------ .../commons/di/LifeCycleComponents.scala | 2 - .../commons/macros/di/ComponentMacros.scala | 19 +++++++- 4 files changed, 65 insertions(+), 37 deletions(-) diff --git a/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala b/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala index ec3bad774..65f02f6b8 100644 --- a/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala +++ b/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala @@ -1,10 +1,10 @@ package com.avsystem.commons package di -import monix.execution.atomic.{Atomic, AtomicBoolean, AtomicInt} +import monix.execution.atomic.{Atomic, AtomicInt} import org.scalatest.funsuite.AsyncFunSuite -class LifeCycleComponentTest extends AsyncFunSuite with Components { +final class LifeCycleComponentTest extends AsyncFunSuite with Components { object init { val inits: AtomicInt = Atomic(0) @@ -27,22 +27,22 @@ class LifeCycleComponentTest extends AsyncFunSuite with Components { } object disposable { - val destroyed: AtomicBoolean = Atomic(false) + val destroys: AtomicInt = Atomic(0) val component: Component[DisposableComponent] = singleton { new DisposableComponent { - def destroy(): Unit = destroyed.set(true) + def destroy(): Unit = destroys += 1 } } } object asyncDisposable { - val destroyed: AtomicBoolean = Atomic(false) + val destroys: AtomicInt = Atomic(0) val component: Component[AsyncDisposableComponent] = singleton { new AsyncDisposableComponent { def destroy()(implicit ec: ExecutionContext): Future[Unit] = Future { - destroyed.set(true) - }(ec) + destroys += 1 + }(using ec) } } } @@ -65,17 +65,27 @@ class LifeCycleComponentTest extends AsyncFunSuite with Components { _ = assert(asyncInit.inits.get() == 1) } yield assert(c1 eq c2)) - test("DisposableComponent destroy triggers side effect") { - assert(!disposable.destroyed.get()) - disposable.component.destroy.map { _ => - assert(disposable.destroyed.get()) - } - } + test("DisposableComponent destroy triggers side effect")(for { + _ <- Future.unit + _ <- disposable.component.destroy + _ = assert(disposable.destroys.get() == 0) + _ <- disposable.component.init + _ = assert(disposable.destroys.get() == 0) + _ <- disposable.component.destroy + _ = assert(disposable.destroys.get() == 1) + _ <- disposable.component.destroy + _ = assert(disposable.destroys.get() == 1) + } yield succeed) - test("AsyncDisposableComponent destroy triggers side effect") { - assert(!asyncDisposable.destroyed.get()) - asyncDisposable.component.destroy.map { _ => - assert(asyncDisposable.destroyed.get()) - } - } + test("AsyncDisposableComponent destroy triggers side effect")(for { + _ <- Future.unit + _ <- asyncDisposable.component.destroy + _ = assert(asyncDisposable.destroys.get() == 0) + _ <- asyncDisposable.component.init + _ = assert(asyncDisposable.destroys.get() == 0) + _ <- asyncDisposable.component.destroy + _ = assert(asyncDisposable.destroys.get() == 1) + _ <- asyncDisposable.component.destroy + _ = assert(asyncDisposable.destroys.get() == 1) + } yield succeed) } \ No newline at end of file diff --git a/core/src/main/scala/com/avsystem/commons/di/Component.scala b/core/src/main/scala/com/avsystem/commons/di/Component.scala index 3383d5a3d..089a2c840 100644 --- a/core/src/main/scala/com/avsystem/commons/di/Component.scala +++ b/core/src/main/scala/com/avsystem/commons/di/Component.scala @@ -8,10 +8,10 @@ import java.util.concurrent.atomic.AtomicReference import scala.annotation.compileTimeOnly import scala.annotation.unchecked.uncheckedVariance -case class ComponentInitializationException(component: Component[_], cause: Throwable) +case class ComponentInitializationException(component: Component[?], cause: Throwable) extends Exception(s"failed to initialize component ${component.info}", cause) -case class DependencyCycleException(cyclePath: List[Component[_]]) +case class DependencyCycleException(cyclePath: List[Component[?]]) extends Exception(s"component dependency cycle detected:\n${cyclePath.iterator.map(_.info).map(" " + _).mkString(" ->\n")}") case class ComponentInfo( @@ -41,7 +41,7 @@ object ComponentInfo { */ final class Component[+T]( val info: ComponentInfo, - deps: => IndexedSeq[Component[_]], + deps: => IndexedSeq[Component[?]], creator: IndexedSeq[Any] => ExecutionContext => Future[T], destroyer: DestroyFunction[T] = Component.emptyDestroy, cachedStorage: Opt[AtomicReference[Future[T]]] = Opt.Empty, @@ -58,12 +58,12 @@ final class Component[+T]( * Returns dependencies of this component extracted from the component definition. * You can use this to inspect the dependency graph without initializing any components. */ - lazy val dependencies: IndexedSeq[Component[_]] = deps + lazy val dependencies: IndexedSeq[Component[?]] = deps private[this] val storage: AtomicReference[Future[T]] = cachedStorage.getOrElse(new AtomicReference) - private def sameStorage(otherStorage: AtomicReference[_]): Boolean = + private def sameStorage(otherStorage: AtomicReference[?]): Boolean = storage eq otherStorage // equality based on storage identity is important for cycle detection with cached components @@ -106,7 +106,7 @@ final class Component[+T]( /** * Forces a dependency on another component or components. */ - def dependsOn(moreDeps: Component[_]*): Component[T] = + def dependsOn(moreDeps: Component[?]*): Component[T] = new Component(info, deps ++ moreDeps, creator, destroyer, cachedStorage) /** @@ -126,7 +126,7 @@ final class Component[+T]( def destroyWith(destroyFun: T => Unit): Component[T] = asyncDestroyWith(implicit ctx => t => Future(destroyFun(t))) - private[di] def cached(cachedStorage: AtomicReference[Future[T@uncheckedVariance]], info: ComponentInfo): Component[T] = + private[di] def cached(cachedStorage: AtomicReference[Future[T @uncheckedVariance]], info: ComponentInfo): Component[T] = new Component(info, deps, creator, destroyer, Opt(cachedStorage)) /** @@ -171,10 +171,15 @@ final class Component[+T]( val resultFuture = Future.traverse(dependencies)(_.doInit(starting = false)) .flatMap(resolvedDeps => creator(resolvedDeps)(ec)) + .flatMap { + case component: AsyncInitializingComponent => component.init().map(_ => component) + case component: InitializingComponent => Future(component.init()).map(_ => component) + case component => Future.successful(component) + } .recoverNow { case NonFatal(cause) => throw ComponentInitializationException(this, cause) - } + }.asInstanceOf[Future[T]] promise.completeWith(resultFuture) } storage.get() @@ -194,7 +199,7 @@ object Component { def async[T](definition: => T): ExecutionContext => Future[T] = implicit ctx => Future(definition) - def validateAll(components: Seq[Component[_]]): Unit = + def validateAll(components: Seq[Component[?]]): Unit = GraphUtils.dfs(components)( _.dependencies.toList, onCycle = (node, stack) => { @@ -210,9 +215,9 @@ object Component { * (reverse initialization order). * Independent components are destroyed in parallel, using given `ExecutionContext`. */ - def destroyAll(components: Seq[Component[_]])(implicit ec: ExecutionContext): Future[Unit] = { - val reverseGraph = new MHashMap[Component[_], MListBuffer[Component[_]]] - val terminals = new MHashSet[Component[_]] + def destroyAll(components: Seq[Component[?]])(implicit ec: ExecutionContext): Future[Unit] = { + val reverseGraph = new MHashMap[Component[?], MListBuffer[Component[?]]] + val terminals = new MHashSet[Component[?]] GraphUtils.dfs(components)( _.dependencies.toList, onEnter = { (c, _) => @@ -225,9 +230,9 @@ object Component { terminals += c }, ) - val destroyFutures = new MHashMap[Component[_], Future[Unit]] + val destroyFutures = new MHashMap[Component[?], Future[Unit]] - def doDestroy(c: Component[_]): Future[Unit] = + def doDestroy(c: Component[?]): Future[Unit] = destroyFutures.getOrElseUpdate(c, Future.traverse(reverseGraph(c))(doDestroy).flatMap(_ => c.doDestroy)) Future.traverse(reverseGraph.keys)(doDestroy).toUnit diff --git a/core/src/main/scala/com/avsystem/commons/di/LifeCycleComponents.scala b/core/src/main/scala/com/avsystem/commons/di/LifeCycleComponents.scala index 80559c5e6..67ddd96fc 100644 --- a/core/src/main/scala/com/avsystem/commons/di/LifeCycleComponents.scala +++ b/core/src/main/scala/com/avsystem/commons/di/LifeCycleComponents.scala @@ -3,7 +3,6 @@ package di trait InitializingComponent { def init(): Unit - final def initialized(): this.type = init().thenReturn(this) } trait DisposableComponent { @@ -12,7 +11,6 @@ trait DisposableComponent { trait AsyncInitializingComponent { def init()(implicit ec: ExecutionContext): Future[Unit] - final def initialized()(implicit ec: ExecutionContext): Future[this.type] = init()(using ec).map[this.type](_ => this)(using ec) } trait AsyncDisposableComponent { diff --git a/macros/src/main/scala/com/avsystem/commons/macros/di/ComponentMacros.scala b/macros/src/main/scala/com/avsystem/commons/macros/di/ComponentMacros.scala index 937900599..9cc9e94db 100644 --- a/macros/src/main/scala/com/avsystem/commons/macros/di/ComponentMacros.scala +++ b/macros/src/main/scala/com/avsystem/commons/macros/di/ComponentMacros.scala @@ -4,6 +4,7 @@ package macros.di import com.avsystem.commons.macros.AbstractMacroCommons import scala.collection.mutable.ListBuffer +import scala.concurrent.{ExecutionContext, Future} import scala.reflect.macros.blackbox class ComponentMacros(ctx: blackbox.Context) extends AbstractMacroCommons(ctx) { @@ -19,6 +20,11 @@ class ComponentMacros(ctx: blackbox.Context) extends AbstractMacroCommons(ctx) { lazy val InjectSym: Symbol = getType(tq"$DiPkg.Components").member(TermName("inject")) lazy val ComponentInfoSym: Symbol = getType(tq"$DiPkg.ComponentInfo.type").member(TermName("info")) + lazy val DisposableComponentTpe: Type = getType(tq"$DiPkg.DisposableComponent") + lazy val AsyncDisposableComponentTpe: Type = getType(tq"$DiPkg.AsyncDisposableComponent") + lazy val ExecutionContextTpe: Type = typeOf[ExecutionContext] + lazy val FutureApplySym: Symbol = typeOf[Future.type].member(TermName("apply")) + object ComponentRef { def unapply(tree: Tree): Option[Tree] = tree match { case Select(component, TermName("ref")) if tree.symbol == ComponentRefSym => @@ -90,16 +96,25 @@ class ComponentMacros(ctx: blackbox.Context) extends AbstractMacroCommons(ctx) { if (needsRetyping) c.untypecheck(transformedDefinition) else definition val asyncDefinition = - if(async) finalDefinition + if (async) finalDefinition else q"$DiPkg.Component.async($finalDefinition)" + val destroyer = + if (tpe <:< DisposableComponentTpe) + q"(ec: $ExecutionContextTpe) => (t: $tpe) => $FutureApplySym(t.destroy())(using ec)" + else if (tpe <:< AsyncDisposableComponentTpe) + q"(ec: $ExecutionContextTpe) => (t: $tpe) => t.destroy()(using ec)" + else + q"$DiPkg.Component.emptyDestroy" + val result = q""" val $infoName = ${c.prefix}.componentInfo($sourceInfo) new $DiPkg.Component[$tpe]( $infoName, $ScalaPkg.IndexedSeq(..${depsBuf.result()}), - ($depArrayName: $ScalaPkg.IndexedSeq[$ScalaPkg.Any]) => $asyncDefinition + ($depArrayName: $ScalaPkg.IndexedSeq[$ScalaPkg.Any]) => $asyncDefinition, + $destroyer, ) """ From 0dc7e682d6ab2385a5bbc1425916586ee9ed3294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 24 Sep 2025 13:13:26 +0200 Subject: [PATCH 3/4] fix analyzer and lifecycle component tests: adjust type annotation and trailing newline --- .../avsystem/commons/analyzer/BadSingletonComponentTest.scala | 2 +- .../scala/com/avsystem/commons/di/LifeCycleComponentTest.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/analyzer/src/test/scala/com/avsystem/commons/analyzer/BadSingletonComponentTest.scala b/analyzer/src/test/scala/com/avsystem/commons/analyzer/BadSingletonComponentTest.scala index a8c4bf51a..4c02b0825 100644 --- a/analyzer/src/test/scala/com/avsystem/commons/analyzer/BadSingletonComponentTest.scala +++ b/analyzer/src/test/scala/com/avsystem/commons/analyzer/BadSingletonComponentTest.scala @@ -13,7 +13,7 @@ final class BadSingletonComponentTest extends AnyFunSuite with AnalyzerTest { | singleton(123) | val notDef = singleton(123) | def hasParams(param: Int) = singleton(param) - | def hasTypeParams[T]: Component[T] = singleton(???) + | def hasTypeParams[T]: Component[T] = singleton(??? :T) | def outerMethod: Component[Int] = { | def innerMethod = singleton(123) | innerMethod diff --git a/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala b/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala index 65f02f6b8..86a4e1630 100644 --- a/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala +++ b/core/jvm/src/test/scala/com/avsystem/commons/di/LifeCycleComponentTest.scala @@ -88,4 +88,4 @@ final class LifeCycleComponentTest extends AsyncFunSuite with Components { _ <- asyncDisposable.component.destroy _ = assert(asyncDisposable.destroys.get() == 1) } yield succeed) -} \ No newline at end of file +} From 009d7b680806683f10b7a8af4a080ea15c8367b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 24 Sep 2025 13:21:54 +0200 Subject: [PATCH 4/4] add documentation for lifecycle-aware components with initialization and disposal examples --- docs/Components.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/docs/Components.md b/docs/Components.md index de9549692..e39c095db 100644 --- a/docs/Components.md +++ b/docs/Components.md @@ -164,6 +164,69 @@ object MyApp extends Components { } ``` +## Lifecycle-aware components + +Many application services have explicit initialization and shutdown phases. Components support this out of the box. + +There are four lifecycle traits you can implement on the values produced by your components: + +- InitializingComponent — synchronous init hook def init(): Unit +- AsyncInitializingComponent — asynchronous init hook def init()(implicit ec: ExecutionContext): Future[Unit] +- DisposableComponent — synchronous shutdown hook def destroy(): Unit +- AsyncDisposableComponent — asynchronous shutdown hook def destroy()(implicit ec: ExecutionContext): Future[Unit] + +How it works: + +- When you call component.init, after all dependencies have been initialized, the framework automatically detects + if the created instance implements InitializingComponent or AsyncInitializingComponent and invokes its init hook. + For cached components created with singleton/asyncSingleton the init hook is executed only once and subsequent + init calls return the same instance without re-running the hook. +- When you call component.destroy, components are destroyed in reverse dependency order. If a component’s type + implements DisposableComponent or AsyncDisposableComponent, its destroy hook is called automatically. Independent + components are destroyed in parallel where possible. For cached components, destroy clears the cached instance so + the next init will create a new instance. +- Destroy is a no-op for components that were never initialized in the current process (nothing is created, so + there’s nothing to destroy). + +Custom cleanup: + +- You can register additional cleanup with destroyWith/asyncDestroyWith to complement lifecycle traits: + +```scala +val httpServer: Component[HttpServer] = + component(new HttpServer(db.ref)) + .destroyWith(_.close()) // runs in addition to lifecycle destroy hooks if present +``` + +Example with lifecycle hooks: + +```scala +import com.avsystem.commons.di._ +import scala.concurrent.{ExecutionContext, Future} + +final class Cache extends InitializingComponent with DisposableComponent { + def init(): Unit = println("warming up cache...") + def destroy(): Unit = println("clearing cache...") +} + +object MyApp extends Components { + def cache: Component[Cache] = singleton(new Cache) +} + +// Somewhere in your main: +import ExecutionContext.Implicits.global +for { + _ <- MyApp.cache.init // prints "warming up cache..." only once + _ <- MyApp.cache.destroy // prints "clearing cache..." +} yield () +``` + +Notes: + +- Lifecycle init hooks are invoked by Component during init. Lifecycle destroy hooks are wired into the destroy + function by the component macros based on the component’s static type, so you don’t need to call destroyWith + explicitly when using DisposableComponent/AsyncDisposableComponent. + ## Complete example See [ComponentsExample.scala](https://github.com/AVSystem/scala-commons/blob/master/core/jvm/src/test/scala/com/avsystem/commons/di/ComponentsExample.scala)