Transactions¶
A transaction runs a block of code with a consistent view of your flags. Inside it you can:
- override flags, without touching the provider;
- cache each flag, so every read in the block sees the same value;
- record every flag the block read, for an audit trail or a test assertion.
A first transaction¶
val run: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[(Boolean, Int)]] =
FeatureFlags.transaction(overrides = Map("feature-a" -> true, "max-items" -> 50)) {
for
a <- FeatureFlags.boolean("feature-a", default = false) // true: overridden
n <- FeatureFlags.int("max-items", default = 10) // 50: overridden
yield (a, n)
}
val report: ZIO[FeatureFlags, FeatureFlagError, String] =
run.map { tx =>
s"result=${tx.result} flags=${tx.flagCount} overridden=${tx.overrideCount}"
// result=(true,50) flags=2 overridden=2
}
transaction returns a TransactionResult holding the block's result and what it read. Keys without an override are
evaluated by the provider as usual:
// Keys without an override go to the provider as usual
val mixed: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[(Boolean, Boolean)]] =
FeatureFlags.transaction(Map("feature-a" -> true)) {
FeatureFlags.boolean("feature-a", false) <*> FeatureFlags.boolean("feature-b", false)
}
Parameters¶
| Parameter | Default | Meaning |
|---|---|---|
overrides |
Map.empty |
flag key → value to serve instead of asking the provider |
context |
EvaluationContext.empty |
context for every evaluation in the block |
cacheEvaluations |
true |
serve repeated reads of a key from the transaction |
nested |
NestedPolicy.Fail |
what to do inside another transaction; see nesting |
Overrides¶
An override is served as is: the provider is not asked, and the result has reason Cached. Overrides work even while
the provider is not ready, which makes them a way to force known values in tests or during an outage.
The override must match the type the flag is read as; otherwise the read fails with OverrideTypeMismatch:
// "count" is read as an Int, but the override is a String: the read fails with OverrideTypeMismatch
val mismatch: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Int]] =
FeatureFlags.transaction(Map("count" -> "not-a-number")) {
FeatureFlags.int("count", 0)
}
A custom flag type can be overridden with its value or with the value the provider would store:
enum Phase derives FlagType:
case Off, DualWrite, ShardOnly
// A custom type can be overridden by its value, or by what the provider would store; both give Phase.DualWrite
val byValue: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Phase]] =
FeatureFlags.transaction(Map("rollout-phase" -> Phase.DualWrite)) {
FeatureFlags.value[Phase]("rollout-phase", Phase.Off)
}
val byWireValue: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Phase]] =
FeatureFlags.transaction(Map("rollout-phase" -> "DualWrite")) {
FeatureFlags.value[Phase]("rollout-phase", Phase.Off)
}
Caching¶
With cacheEvaluations = true (the default), the first read of a key calls the provider and the next reads of that
key in the block are served from the transaction with reason Cached. The block then sees one value per flag even if
the flag changes meanwhile.
// The first read goes to the provider; the next two are served from the transaction (reason = Cached)
val cached: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[List[FlagResolution[Boolean]]]] =
FeatureFlags.transaction() {
ZIO.foreach(List(1, 2, 3))(_ => FeatureFlags.booleanDetails("feature", false))
}
// Every read goes to the provider, so a change between reads is visible
val uncached: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[(Boolean, Boolean)]] =
FeatureFlags.transaction(cacheEvaluations = false) {
FeatureFlags.boolean("feature", false) <*> FeatureFlags.boolean("feature", false)
}
- The cache is keyed by flag key only. A second read with a different context gets the cached value.
- An answer that carries an error code (such as
FLAG_NOT_FOUND) is recorded but never served from the cache; the next read asks the provider again. - A key read again as another type is served from the cache when the cached value converts (an
Intread as aLong), and from the provider otherwise.
Transaction context¶
// Context for every evaluation in the block. It ranks just above the global context, so the client context,
// `withContext` and the call's own context all win over it.
val withTxContext: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Boolean]] =
FeatureFlags.transaction(context = EvaluationContext("user-123").withAttribute("plan", "premium")) {
FeatureFlags.boolean("premium-feature", false)
}
In the merge order the transaction context ranks just above the global context: the
client context, a context source, withContext and the call's own context all win over it.
What a transaction returns¶
def describe(tx: TransactionResult[?]): String =
val providerKeys = tx.providerEvaluatedKeys // read from the provider
val overridden = tx.overriddenFlags // served from an override
val values = tx.toValueMap // key -> value, for every flag read
val details = tx.getEvaluation("feature-b").map(e => s"${e.value} at ${e.timestamp}")
s"provider=$providerKeys overridden=$overridden values=$values feature-b=${details.getOrElse("not read")}"
TransactionResult member |
Meaning |
|---|---|
result |
the block's result |
evaluatedFlags |
key → FlagEvaluation, for every flag read |
overriddenFlags |
keys served from an override |
allFlagKeys |
every key read |
providerEvaluatedKeys |
keys read from the provider |
flagCount, overrideCount |
how many of each |
getEvaluation(key) |
that key's FlagEvaluation, if read |
wasEvaluated(key), wasOverridden(key) |
membership checks |
toValueMap |
key → value |
map(f) |
transform result |
A FlagEvaluation holds key, value, the full resolution, wasOverridden (and its opposite, wasEvaluated) and a
timestamp. A cached re-read does not add a second entry.
Errors¶
On Scala 3, transaction fails with your block's error or a FeatureFlagError (a union type). transactionEither
keeps them apart, with your error as Left and the transaction's as Right. Use it on Scala 2.13, where
transaction's error type is Any.
// transactionEither keeps your error (Left) apart from the transaction's own (Right), on Scala 2.13 too
val either: ZIO[FeatureFlags, Either[String, FeatureFlagError], TransactionResult[Boolean]] =
FeatureFlags.transactionEither() {
FeatureFlags.boolean("feature", false).orElseFail("could not read the flag")
}
Nesting¶
By default, opening a transaction inside another fails with NestedTransactionNotAllowed before the inner block runs.
With nested = NestedPolicy.Reuse, the inner call joins the outer transaction instead. That makes a transaction safe
to use as middleware around code that may open its own.
val inner: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Boolean]] =
FeatureFlags.transaction(nested = NestedPolicy.Reuse) {
FeatureFlags.boolean("feature-a", false)
}
// With the default, NestedPolicy.Fail, the inner call would fail with NestedTransactionNotAllowed.
// With Reuse it joins the outer transaction: it sees the outer overrides and its own arguments are ignored.
val outer: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[Boolean]] =
FeatureFlags.transaction(Map("feature-a" -> true)) {
inner.map(_.result) // true, from the outer override
}
Warning
Under Reuse the inner call's overrides, context and cacheEvaluations are ignored, and its result describes
the outer transaction so far.
Is there a transaction?¶
// For an audit record: None outside a transaction, Some(flags) inside one (possibly empty)
val auditFlags: ZIO[FeatureFlags, Nothing, Option[Set[String]]] =
FeatureFlags.transactionEvaluations.map(_.map(_.keySet))
val state: ZIO[FeatureFlags, FeatureFlagError, (Boolean, Option[Set[String]])] =
for
inside <- FeatureFlags.inTransaction // false here
tx <- FeatureFlags.transaction() {
FeatureFlags.boolean("feature", false) *> auditFlags // Some(Set("feature"))
}
yield (inside, tx.result)
| Method | Outside a transaction | Inside one |
|---|---|---|
inTransaction |
false |
true |
transactionEvaluations |
None |
Some(flags read so far) |
currentEvaluatedFlags |
Map.empty |
flags read so far |
For an audit record prefer transactionEvaluations: it cannot confuse "no transaction" with "no flags read".
Fibers¶
A transaction belongs to the fiber that opened it and to the fibers it forks. Forked fibers use its overrides and cache, and their reads are recorded in it. Other fibers are not affected.
// Fibers forked inside the block share the transaction: their reads use its overrides and are recorded in it
val parallel: ZIO[FeatureFlags, FeatureFlagError, TransactionResult[List[Boolean]]] =
FeatureFlags.transaction(Map("feature-a" -> true)) {
ZIO.foreachPar(List("feature-a", "feature-b", "feature-c"))(key => FeatureFlags.boolean(key, false))
}