Skip to content

mapN and parMapN don't appear to compose with or #850

@morgen-peschke

Description

@morgen-peschke

Tested against 3.8.0

We have a configuration that shares a few parameters across environments and needs different parameters for local development and production. We are representing this as a sealed trait, which can be minimized to this:

sealed trait Config extends Product with Serializable
final case class DevConfig(a: Int, b: String) extends Config
final case class ProdConfig(a: Int, c: Int) extends Config

Originally, the ConfigValue was modeled by creating a ConfigValue[Effect, DevConfig] and a ConfigValue[Effect, ProdConfig], and composing them with or, which can be simplified to this:

val a = ciris.env("TEST_A").as[Int]
val b = ciris.env("TEST_B")
val c = ciris.env("TEST_C").as[Int]

val parMapNInsideOr: ConfigValue[Effect, Config] = {
  val dev = (a, b).parMapN(DevConfig).widen[Config]
  val prod = (a, c).parMapN(ProdConfig).widen[Config]
  dev.or(prod)
}

This doesn't seem to work, which appears to be related to the interplay between ciris.ConfigError#isMissing and ciris.ConfigError#or.

I had to kind of crack the package visibility to get visibility into why this was happening, by testing in a sandbox using a _root_.ciris package 😅 , and adding this hack:

implicit final class Helpers[F[_], A](private val cv: ConfigValue[F, A]) extends AnyVal {
  def debug(tag: String): ConfigValue[F, A] = cv.transform { ce =>
    println(s"[$tag] $ce")
    ce
  }
}

Instrumenting the minimized version like this (switching to mapN for stable order, but the results are the same with parMapN):

val a = ciris.env("TEST_A").as[Int].debug("a")
val b = ciris.env("TEST_B").debug("b")
val c = ciris.env("TEST_C").as[Int].debug("c")

val mapNInsideOr: ConfigValue[Effect, Config] = {
  val dev = (a, b).mapN(DevConfig).widen[Config].debug("DevConfig")
  val prod = (a, c).mapN(ProdConfig).widen[Config].debug("ProdConfig")
  dev.or(prod).debug("devOrProd")
}

And setting these environment variables:

export TEST_A=1
export TEST_C=2

Produces this output:

[a] Loaded(Loaded, Some(ConfigKey(environment variable TEST_A)), 1)
[b] Failed(Missing(ConfigKey(environment variable TEST_B)))
[DevConfig] Failed(And(Loaded, Missing(ConfigKey(environment variable TEST_B))))
[devOrProd] Failed(And(Loaded, Missing(ConfigKey(environment variable TEST_B))))

This seems like it should have attempted to load the prod config, which did not happen, and I think the reason for this is that the failure includes the successfully loaded a, so the check errors.forall(_.isMissing) in ciris.ConfigError#isMissing fails.

Interestingly, inverting this does work as expected (though this rapidly fails to scale with the complexity of the ADT):

val orInsideMapN: ConfigValue[Effect, Config] = {
  (
    a,
    b.map(_.asLeft[Int]).or(c.map(_.asRight)).debug("bOrC")
  ).mapN { (a, bOrC) =>
    bOrC.fold(
      DevConfig(a, _),
      ProdConfig(a, _)
    )
  }.debug("config")
}

With the same environment as above, this produces:

[a] Loaded(Loaded, Some(ConfigKey(environment variable TEST_A)), 1)
[b] Failed(Missing(ConfigKey(environment variable TEST_B)))
[c] Loaded(Loaded, Some(ConfigKey(environment variable TEST_C)), 2)
[bOrC] Loaded(Or(Loaded, Missing(ConfigKey(environment variable TEST_B))), Some(ConfigKey(environment variable TEST_C)), Right(5))
[69] [config] Loaded(Loaded, None, ProdConfig(5,5))

I've uploaded the full code I used to test in this gist

Is this behavior intended?
If so, is there a better way to compose ADTs built from environment variables that I missed?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions