Skip to content

Commit

Permalink
add feature-flag client (#21091)
Browse files Browse the repository at this point in the history
* feature-flag client; wip

* feature-flag; wip

* updated wip/poc

* make account truly optional

* add temp flag example

* wip of feature-flag client; upgrade to kotlin 1.8

* inject LDClient

* feature-flag client wip; add tests; finalize classes

* add cloud test with mockk

* add EnvVar tests

* remove account from workspace, add user

* comment out test

* doc typo

* move flags to top of file

* change LogConnectorMessages to EnvVar

* add @jvmoverloads to Workspace context

* rename clients; add TestClient (with tests)

* rename teams

* rename Client -> FeatureFlagClient

* doc typo

* change parameter ordering in flags

* rename files

* typo

* update docs to match re-org'd params

* rename PlatformClient -> ConfigFileClient; CloudClient -> LaunchDarklyClient

* pr feedback; comment updates

* pr feedback - remove star imports

* pr feedback; update to use version catalog

* pr feedback; add Connection, Source, Destination contexts

* pr feedback - add multicontext support

* replace Team idea with attributes map

* pr feedback

* explicit types

* rename data classes to match client

* pass context to envvar flags; make enabled open

* change fetcher to an internal property

* add UUID secondary constructors to context classes

* make values optional for TestClient

* add anonymous support

* pr-feedback; add newlines to each file
  • Loading branch information
colesnodgrass authored Jan 17, 2023
1 parent f31d92c commit 4731a9c
Show file tree
Hide file tree
Showing 15 changed files with 1,061 additions and 0 deletions.
39 changes: 39 additions & 0 deletions airbyte-featureflag/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
`java-library`
kotlin("jvm") version "1.8.0"
}

dependencies {
annotationProcessor(platform(libs.micronaut.bom))
annotationProcessor(libs.bundles.micronaut.annotation.processor)

implementation(platform(libs.micronaut.bom))
implementation(libs.micronaut.inject)
implementation(libs.launchdarkly)
implementation(libs.jackson.databind)
implementation(libs.jackson.dataformat)
implementation(libs.jackson.kotlin)

testAnnotationProcessor(platform(libs.micronaut.bom))
testAnnotationProcessor(libs.micronaut.inject)
testAnnotationProcessor(libs.bundles.micronaut.test.annotation.processor)

testImplementation(kotlin("test"))
testImplementation(kotlin("test-junit5"))
testImplementation(libs.bundles.micronaut.test)
testImplementation(libs.mockk)
testImplementation(libs.bundles.junit)
}

tasks.withType<KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

tasks.test {
useJUnitPlatform()
}
211 changes: 211 additions & 0 deletions airbyte-featureflag/src/main/kotlin/Client.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/*
* Copyright (c) 2022 Airbyte, Inc., all rights reserved.
*/

package io.airbyte.featureflag

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import com.launchdarkly.sdk.ContextKind
import com.launchdarkly.sdk.LDContext
import com.launchdarkly.sdk.LDUser
import com.launchdarkly.sdk.server.LDClient
import java.lang.Thread.MIN_PRIORITY
import java.nio.file.Path
import java.nio.file.StandardWatchEventKinds
import java.nio.file.WatchService
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.thread
import kotlin.concurrent.write
import kotlin.io.path.isRegularFile

/**
* Feature-Flag Client interface.
*/
sealed interface FeatureFlagClient {
/**
* Returns true if the flag with the provided context should be enabled. Returns false otherwise.
*/
fun enabled(flag: Flag, ctx: Context): Boolean
}

/**
* Config file based feature-flag client. Feature-flag are derived from a yaml config file.
* Also supports flags defined via environment-variables via the [EnvVar] class.
*
* @param [config] the location of the yaml config file that contains the feature-flag definitions.
* The [config] will be watched for changes and the internal representation of the [config] will be updated to match.
*/
class ConfigFileClient(config: Path) : FeatureFlagClient {
/** [flags] holds the mappings of the flag-name to the flag properties */
private var flags: Map<String, ConfigFileFlag> = readConfig(config)

/** lock is used for ensuring access to the flags map is handled correctly when the map is being updated. */
private val lock = ReentrantReadWriteLock()

init {
if (!config.isRegularFile()) {
throw IllegalArgumentException("config must reference a file")
}

config.onChange {
lock.write { flags = readConfig(config) }
}
}

override fun enabled(flag: Flag, ctx: Context): Boolean {
return when (flag) {
is EnvVar -> flag.enabled(ctx)
else -> lock.read { flags[flag.key]?.enabled ?: flag.default }
}
}
}

/**
* LaunchDarkly based feature-flag client. Feature-flags are derived from an external source (the LDClient).
* Also supports flags defined via environment-variables via the [EnvVar] class.
*
* @param [client] the Launch-Darkly client for interfacing with Launch-Darkly.
*/
class LaunchDarklyClient(private val client: LDClient) : FeatureFlagClient {
override fun enabled(flag: Flag, ctx: Context): Boolean {
return when (flag) {
is EnvVar -> flag.enabled(ctx)
else -> client.boolVariation(flag.key, ctx.toLDUser(), flag.default)
}
}
}

/**
* Test feature-flag client. Intended only for usage in testing scenarios.
*
* All [Flag] instances will use the provided [values] map as their source of truth, including [EnvVar] flags.
*
* @param [values] is a map of [Flag.key] to enabled/disabled status.
*/
class TestClient @JvmOverloads constructor(val values: Map<String, Boolean> = mapOf()) : FeatureFlagClient {
override fun enabled(flag: Flag, ctx: Context): Boolean {
return when (flag) {
is EnvVar -> {
// convert to a EnvVar flag with a custom fetcher that uses the [values] of this Test class
// instead of fetching from the environment variables
EnvVar(envVar = flag.key, default = flag.default, attrs = flag.attrs).apply {
fetcher = { values[flag.key]?.toString() ?: flag.default.toString() }
}.enabled(ctx)
}

else -> values[flag.key] ?: flag.default
}
}
}


/**
* Data wrapper around OSS feature-flag configuration file.
*
* The file has the format of:
* flags:
* - name: feature-one
* enabled: true
* - name: feature-two
* enabled: false
*/
private data class ConfigFileFlags(val flags: List<ConfigFileFlag>)

/**
* Data wrapper around an individual flag read from the configuration file.
*/
private data class ConfigFileFlag(val name: String, val enabled: Boolean)


/** The yaml mapper is used for reading the feature-flag configuration file. */
private val yamlMapper = ObjectMapper(YAMLFactory()).registerKotlinModule()

/**
* Reads a yaml configuration file, converting it into a map of flag name to flag configuration.
*
* @param [path] to yaml config file
* @return map of feature-flag name to feature-flag config
*/
private fun readConfig(path: Path): Map<String, ConfigFileFlag> = yamlMapper.readValue<ConfigFileFlags>(path.toFile()).flags
.associateBy { it.name }

/**
* Monitors a [Path] for changes, calling [block] when a change is detected.
*
* @receiver Path
* @param [block] function called anytime a change is detected on this [Path]
*/
private fun Path.onChange(block: () -> Unit) {
val watcher: WatchService = fileSystem.newWatchService()
// The watcher service requires a directory to be registered and not an individual file. This Path is an individual file,
// hence the `parent` reference to register the parent of this file (which is the directory that contains this file).
// As all files within this directory could send events, any file that doesn't match this Path will need to be filtered out.
parent.register(watcher, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_CREATE)

thread(isDaemon = true, name = "feature-flag-watcher", priority = MIN_PRIORITY) {
val key = watcher.take()
// The context on the poll-events for ENTRY_MODIFY and ENTRY_CREATE events should return a Path,
// however officially `Returns: the event context; may be null`, so there is a null check here
key.pollEvents().mapNotNull { it.context() as? Path }
// As events are generated at the directory level and not the file level, any files that do not match the specific file
// this Path represents must be filtered out.
// E.g.
// If this path is "/tmp/dir/flags.yml",
// the directory registered with the WatchService was "/tmp/dir",
// and the event's path would be "flags.yml".
//
// This filter verifies that "/tmp/dir/flags.yml" ends with "flags.yml" before calling the block method.
.filter { this.endsWith(it) }
.forEach { _ -> block() }

key.reset()
}
}

/**
* LaunchDarkly v5 version
*
* LaunchDarkly v6 introduces the LDContext class which replaces the LDUser class,
* however this is only available to early-access users accounts currently.
*
* Once v6 is GA, this method would be removed and replaced with toLDContext.
*/
private fun Context.toLDUser(): LDUser = when (this) {
is Multi -> throw IllegalArgumentException("LDv5 does not support multiple contexts")
else -> {
val builder = LDUser.Builder(key)
if (this.key == ANONYMOUS.toString()) {
builder.anonymous(true)
}
builder.build()
}
}

/**
* LaunchDarkly v6 version
*
* Replaces toLDUser once LaunchDarkly v6 is GA.
*/
private fun Context.toLDContext(): LDContext {
if (this is Multi) {
val builder = LDContext.multiBuilder()
contexts.forEach { builder.add(it.toLDContext()) }
return builder.build()
}

val builder = LDContext.builder(ContextKind.of(kind), key)
if (key == ANONYMOUS.toString()) {
builder.anonymous(true)
}

when (this) {
is Workspace -> user?.let { builder.set("user", it) }
else -> Unit
}

return builder.build()
}
147 changes: 147 additions & 0 deletions airbyte-featureflag/src/main/kotlin/Context.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2023 Airbyte, Inc., all rights reserved.
*/

package io.airbyte.featureflag

import java.util.*

/**
* Anonymous UUID to be used with anonymous contexts.
*
* Annotated with @JvmField for java interop.
*/
@JvmField
val ANONYMOUS = UUID(0, 0)

/**
* Context abstraction around LaunchDarkly v6 context idea
*
* I'm still playing around with this. Basically the idea is to define our own custom context types
* (by implementing this sealed interface) to ensure that we are consistently using the same identifiers
* throughout the code.
*
* @property [kind] determines the kind of context the implementation is,
* must be consistent for each type and should not be set by the caller of a context
* @property [key] is the unique identifier for the specific context, e.g. a user-id or workspace-id
*/
sealed interface Context {
val kind: String
val key: String
}

/**
* Context for representing multiple contexts concurrently. Only supported for LaunchDarkly v6!
*
* @param [contexts] list of contexts, must not contain another Multi context
*/
data class Multi(val contexts: List<Context>) : Context {
/** This value MUST be "multi" to properly sync with the LaunchDarkly client. */
override val kind = "multi"

/** Multi contexts don't have a key */
override val key = ""

init {
// ensure there are no nested contexts (i.e. this Multi does not contain another Multi)
if (fetchContexts<Multi>().isNotEmpty()) {
throw IllegalArgumentException("Multi contexts cannot be nested")
}
}

/**
* Returns all the [Context] types contained within this [Multi] matching type [T].
*
* @param [T] the [Context] type to fetch.
* @return all [Context] of [T] within this [Multi], or an empty list if none match.
*/
internal inline fun <reified T> fetchContexts(): List<T> {
return contexts.filterIsInstance<T>()
}
}

/**
* Context for representing a workspace.
*
* @param [key] the unique identifying value of this workspace
* @param [user] an optional user identifier
*/
data class Workspace @JvmOverloads constructor(
override val key: String,
val user: String? = null
) : Context {
override val kind = "workspace"

/**
* Secondary constructor
*
* @param [key] workspace UUID
* @param [user] an optional user identifier
*/
@JvmOverloads
constructor(key: UUID, user: String? = null) : this(key = key.toString(), user = user)
}

/**
* Context for representing a user.
*
* @param [key] the unique identifying value of this user
*/
data class User(override val key: String) : Context {
override val kind = "user"

/**
* Secondary constructor
*
* @param [key] user UUID
*/
constructor(key: UUID) : this(key = key.toString())
}

/**
* Context for representing a connection.
*
* @param [key] the unique identifying value of this connection
*/
data class Connection(override val key: String) : Context {
override val kind = "connection"

/**
* Secondary constructor
*
* @param [key] connection UUID
*/
constructor(key: UUID) : this(key = key.toString())
}

/**
* Context for representing a source.
*
* @param [key] the unique identifying value of this source
*/
data class Source(override val key: String) : Context {
override val kind = "source"

/**
* Secondary constructor
*
* @param [key] Source UUID
*/
constructor(key: UUID) : this(key = key.toString())
}

/**
* Context for representing a destination.
*
* @param [key] the unique identifying value of this destination
*/
data class Destination(override val key: String) : Context {
override val kind = "destination"

/**
* Secondary constructor
*
* @param [key] Destination UUID
*/
constructor(key: UUID) : this(key = key.toString())
}
Loading

0 comments on commit 4731a9c

Please sign in to comment.