Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.avsystem.commons
package di

import monix.execution.atomic.{Atomic, AtomicInt}
import org.scalatest.funsuite.AsyncFunSuite

final 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 destroys: AtomicInt = Atomic(0)
val component: Component[DisposableComponent] = singleton {
new DisposableComponent {
def destroy(): Unit = destroys += 1
}
}
}


object asyncDisposable {
val destroys: AtomicInt = Atomic(0)
val component: Component[AsyncDisposableComponent] = singleton {
new AsyncDisposableComponent {
def destroy()(implicit ec: ExecutionContext): Future[Unit] = Future {
destroys += 1
}(using 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")(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")(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)
}
33 changes: 19 additions & 14 deletions core/src/main/scala/com/avsystem/commons/di/Component.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)

/**
Expand All @@ -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))

/**
Expand Down Expand Up @@ -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]]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A simple .flatMap { value => value match { with mappings to value allows to avoid casting here.

promise.completeWith(resultFuture)
}
storage.get()
Expand All @@ -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) => {
Expand All @@ -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, _) =>
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.avsystem.commons
package di

trait InitializingComponent {
def init(): Unit
}

trait DisposableComponent {
def destroy(): Unit
}

trait AsyncInitializingComponent {
def init()(implicit ec: ExecutionContext): Future[Unit]
}

trait AsyncDisposableComponent {
def destroy()(implicit ec: ExecutionContext): Future[Unit]
}
63 changes: 63 additions & 0 deletions docs/Components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 =>
Expand Down Expand Up @@ -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,
)
"""

Expand Down