Skip to content

Commit 7691723

Browse files
authored
Merge pull request #96 from launchdarkly/eb/ch28430/consul
Consul feature store implementation
2 parents 2a4064c + 5136187 commit 7691723

File tree

8 files changed

+270
-4
lines changed

8 files changed

+270
-4
lines changed

.circleci/config.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,30 +33,35 @@ jobs:
3333
<<: *ruby-docker-template
3434
docker:
3535
- image: circleci/ruby:2.2.10-jessie
36+
- image: consul
3637
- image: redis
3738
- image: amazon/dynamodb-local
3839
test-2.3:
3940
<<: *ruby-docker-template
4041
docker:
4142
- image: circleci/ruby:2.3.7-jessie
43+
- image: consul
4244
- image: redis
4345
- image: amazon/dynamodb-local
4446
test-2.4:
4547
<<: *ruby-docker-template
4648
docker:
4749
- image: circleci/ruby:2.4.5-stretch
50+
- image: consul
4851
- image: redis
4952
- image: amazon/dynamodb-local
5053
test-2.5:
5154
<<: *ruby-docker-template
5255
docker:
5356
- image: circleci/ruby:2.5.3-stretch
57+
- image: consul
5458
- image: redis
5559
- image: amazon/dynamodb-local
5660
test-jruby-9.2:
5761
<<: *ruby-docker-template
5862
docker:
5963
- image: circleci/jruby:9-jdk
64+
- image: consul
6065
- image: redis
6166
- image: amazon/dynamodb-local
6267

@@ -95,8 +100,19 @@ jobs:
95100
mv Gemfile.lock "Gemfile.lock.$i"
96101
done
97102
- run:
103+
name: start DynamoDB
98104
command: docker run -p 8000:8000 amazon/dynamodb-local
99105
background: true
106+
- run:
107+
name: download Consul
108+
command: wget https://releases.hashicorp.com/consul/0.8.0/consul_0.8.0_linux_amd64.zip
109+
- run:
110+
name: extract Consul
111+
command: unzip consul_0.8.0_linux_amd64.zip
112+
- run:
113+
name: start Consul
114+
command: ./consul agent -dev
115+
background: true
100116
- run:
101117
name: run tests for all versions
102118
shell: /bin/bash -leo pipefail

Gemfile.lock

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ PATH
66
faraday (>= 0.9, < 2)
77
faraday-http-cache (>= 1.3.0, < 3)
88
json (>= 1.8, < 3)
9+
ld-eventsource (~> 1.0)
910
net-http-persistent (>= 2.9, < 4.0)
1011
semantic (~> 1.6)
1112

@@ -28,6 +29,9 @@ GEM
2829
concurrent-ruby (1.1.4)
2930
connection_pool (2.2.1)
3031
diff-lcs (1.3)
32+
diplomat (2.0.2)
33+
faraday (~> 0.9)
34+
json
3135
docile (1.1.5)
3236
faraday (0.15.4)
3337
multipart-post (>= 1.2, < 3)
@@ -36,7 +40,6 @@ GEM
3640
ffi (1.9.25)
3741
ffi (1.9.25-java)
3842
hitimes (1.3.0)
39-
hitimes (1.3.0-java)
4043
http_tools (0.4.5)
4144
jmespath (1.4.0)
4245
json (1.8.6)
@@ -92,6 +95,7 @@ DEPENDENCIES
9295
bundler (~> 1.7)
9396
codeclimate-test-reporter (~> 0)
9497
connection_pool (>= 2.1.2)
98+
diplomat (>= 2.0.2)
9599
ldclient-rb!
96100
listen (~> 3.0)
97101
rake (~> 10.0)

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ Note that this gem will automatically switch to using the Rails logger it is det
8282

8383

8484
HTTPS proxy
85-
------------
85+
-----------
86+
8687
The Ruby SDK uses Faraday and Socketry to handle its network traffic. Both of these provide built-in support for the use of an HTTPS proxy. If the HTTPS_PROXY environment variable is present then the SDK will proxy all network requests through the URL provided.
8788

8889
How to set the HTTPS_PROXY environment variable on Mac/Linux systems:
@@ -124,10 +125,11 @@ end
124125
Database integrations
125126
---------------------
126127

127-
Feature flag data can be kept in a persistent store using Redis or DynamoDB. These adapters are implemented in the `LaunchDarkly::Integrations::Redis` and `LaunchDarkly::Integrations::DynamoDB` modules; to use them, call the `new_feature_store` method in the module, and put the returned object in the `feature_store` property of your client configuration. See the [source code](https://github.com/launchdarkly/ruby-client-private/tree/master/lib/ldclient-rb/integrations) and the [SDK reference guide](https://docs.launchdarkly.com/v2.0/docs/using-a-persistent-feature-store) for more information.
128+
Feature flag data can be kept in a persistent store using Redis, DynamoDB, or Consul. These adapters are implemented in the `LaunchDarkly::Integrations::Redis`, `LaunchDarkly::Integrations::DynamoDB`, and `LaunchDarkly::Integrations::Consul` modules; to use them, call the `new_feature_store` method in the module, and put the returned object in the `feature_store` property of your client configuration. See the [API documentation](https://www.rubydoc.info/gems/ldclient-rb/LaunchDarkly/Integrations) and the [SDK reference guide](https://docs.launchdarkly.com/v2.0/docs/using-a-persistent-feature-store) for more information.
128129

129130
Using flag data from a file
130131
---------------------------
132+
131133
For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See [`file_data_source.rb`](https://github.com/launchdarkly/ruby-client/blob/master/lib/ldclient-rb/file_data_source.rb) for more details.
132134

133135
Learn more
@@ -146,7 +148,7 @@ Contributing
146148
See [Contributing](https://github.com/launchdarkly/ruby-client/blob/master/CONTRIBUTING.md)
147149

148150
About LaunchDarkly
149-
-----------
151+
------------------
150152

151153
* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can:
152154
* Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases.

ldclient-rb.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Gem::Specification.new do |spec|
2525
spec.add_development_dependency "bundler", "~> 1.7"
2626
spec.add_development_dependency "rspec", "~> 3.2"
2727
spec.add_development_dependency "codeclimate-test-reporter", "~> 0"
28+
spec.add_development_dependency "diplomat", ">= 2.0.2"
2829
spec.add_development_dependency "redis", "~> 3.3.5"
2930
spec.add_development_dependency "connection_pool", ">= 2.1.2"
3031
spec.add_development_dependency "rake", "~> 10.0"
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
require "json"
2+
3+
module LaunchDarkly
4+
module Impl
5+
module Integrations
6+
module Consul
7+
#
8+
# Internal implementation of the Consul feature store, intended to be used with CachingStoreWrapper.
9+
#
10+
class ConsulFeatureStoreCore
11+
begin
12+
require "diplomat"
13+
CONSUL_ENABLED = true
14+
rescue ScriptError, StandardError
15+
CONSUL_ENABLED = false
16+
end
17+
18+
def initialize(opts)
19+
if !CONSUL_ENABLED
20+
raise RuntimeError.new("can't use Consul feature store without the 'diplomat' gem")
21+
end
22+
23+
@prefix = (opts[:prefix] || LaunchDarkly::Integrations::Consul.default_prefix) + '/'
24+
@logger = opts[:logger] || Config.default_logger
25+
Diplomat.configuration = opts[:consul_config] if !opts[:consul_config].nil?
26+
@logger.info("ConsulFeatureStore: using Consul host at #{Diplomat.configuration.url}")
27+
end
28+
29+
def init_internal(all_data)
30+
# Start by reading the existing keys; we will later delete any of these that weren't in all_data.
31+
unused_old_keys = Set.new
32+
keys = Diplomat::Kv.get(@prefix, { keys: true, recurse: true }, :return)
33+
unused_old_keys.merge(keys) if keys != ""
34+
35+
ops = []
36+
num_items = 0
37+
38+
# Insert or update every provided item
39+
all_data.each do |kind, items|
40+
items.values.each do |item|
41+
value = item.to_json
42+
key = item_key(kind, item[:key])
43+
ops.push({ 'KV' => { 'Verb' => 'set', 'Key' => key, 'Value' => value } })
44+
unused_old_keys.delete(key)
45+
num_items = num_items + 1
46+
end
47+
end
48+
49+
# Now delete any previously existing items whose keys were not in the current data
50+
unused_old_keys.each do |key|
51+
ops.push({ 'KV' => { 'Verb' => 'delete', 'Key' => key } })
52+
end
53+
54+
# Now set the special key that we check in initialized_internal?
55+
ops.push({ 'KV' => { 'Verb' => 'set', 'Key' => inited_key, 'Value' => '' } })
56+
57+
ConsulUtil.batch_operations(ops)
58+
59+
@logger.info { "Initialized database with #{num_items} items" }
60+
end
61+
62+
def get_internal(kind, key)
63+
value = Diplomat::Kv.get(item_key(kind, key), {}, :return) # :return means "don't throw an error if not found"
64+
(value.nil? || value == "") ? nil : JSON.parse(value, symbolize_names: true)
65+
end
66+
67+
def get_all_internal(kind)
68+
items_out = {}
69+
results = Diplomat::Kv.get(kind_key(kind), { recurse: true }, :return)
70+
(results == "" ? [] : results).each do |result|
71+
value = result[:value]
72+
if !value.nil?
73+
item = JSON.parse(value, symbolize_names: true)
74+
items_out[item[:key].to_sym] = item
75+
end
76+
end
77+
items_out
78+
end
79+
80+
def upsert_internal(kind, new_item)
81+
key = item_key(kind, new_item[:key])
82+
json = new_item.to_json
83+
84+
# We will potentially keep retrying indefinitely until someone's write succeeds
85+
while true
86+
old_value = Diplomat::Kv.get(key, { decode_values: true }, :return)
87+
if old_value.nil? || old_value == ""
88+
mod_index = 0
89+
else
90+
old_item = JSON.parse(old_value[0]["Value"], symbolize_names: true)
91+
# Check whether the item is stale. If so, don't do the update (and return the existing item to
92+
# FeatureStoreWrapper so it can be cached)
93+
if old_item[:version] >= new_item[:version]
94+
return old_item
95+
end
96+
mod_index = old_value[0]["ModifyIndex"]
97+
end
98+
99+
# Otherwise, try to write. We will do a compare-and-set operation, so the write will only succeed if
100+
# the key's ModifyIndex is still equal to the previous value. If the previous ModifyIndex was zero,
101+
# it means the key did not previously exist and the write will only succeed if it still doesn't exist.
102+
success = Diplomat::Kv.put(key, json, cas: mod_index)
103+
return new_item if success
104+
105+
# If we failed, retry the whole shebang
106+
@logger.debug { "Concurrent modification detected, retrying" }
107+
end
108+
end
109+
110+
def initialized_internal?
111+
# Unfortunately we need to use exceptions here, instead of the :return parameter, because with
112+
# :return there's no way to distinguish between a missing value and an empty string.
113+
begin
114+
Diplomat::Kv.get(inited_key, {})
115+
true
116+
rescue Diplomat::KeyNotFound
117+
false
118+
end
119+
end
120+
121+
def stop
122+
# There's no Consul client instance to dispose of
123+
end
124+
125+
private
126+
127+
def item_key(kind, key)
128+
kind_key(kind) + key.to_s
129+
end
130+
131+
def kind_key(kind)
132+
@prefix + kind[:namespace] + '/'
133+
end
134+
135+
def inited_key
136+
@prefix + '$inited'
137+
end
138+
end
139+
140+
class ConsulUtil
141+
#
142+
# Submits as many transactions as necessary to submit all of the given operations.
143+
# The ops array is consumed.
144+
#
145+
def self.batch_operations(ops)
146+
batch_size = 64 # Consul can only do this many at a time
147+
while true
148+
chunk = ops.shift(batch_size)
149+
break if chunk.empty?
150+
Diplomat::Kv.txn(chunk)
151+
end
152+
end
153+
end
154+
end
155+
end
156+
end
157+
end

lib/ldclient-rb/integrations.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
require "ldclient-rb/integrations/consul"
12
require "ldclient-rb/integrations/dynamodb"
23
require "ldclient-rb/integrations/redis"
34
require "ldclient-rb/integrations/util/store_wrapper"
@@ -7,6 +8,17 @@ module LaunchDarkly
78
# Tools for connecting the LaunchDarkly client to other software.
89
#
910
module Integrations
11+
#
12+
# Integration with [Consul](https://www.consul.io/).
13+
#
14+
# Note that in order to use this integration, you must first install the gem `diplomat`.
15+
#
16+
# @since 5.5.0
17+
#
18+
module Consul
19+
# code is in ldclient-rb/impl/integrations/consul_impl
20+
end
21+
1022
#
1123
# Integration with [DynamoDB](https://aws.amazon.com/dynamodb/).
1224
#
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
require "ldclient-rb/impl/integrations/consul_impl"
2+
require "ldclient-rb/integrations/util/store_wrapper"
3+
4+
module LaunchDarkly
5+
module Integrations
6+
module Consul
7+
#
8+
# Default value for the `prefix` option for {new_feature_store}.
9+
#
10+
# @return [String] the default key prefix
11+
#
12+
def self.default_prefix
13+
'launchdarkly'
14+
end
15+
16+
#
17+
# Creates a Consul-backed persistent feature store.
18+
#
19+
# To use this method, you must first install the gem `diplomat`. Then, put the object returned by
20+
# this method into the `feature_store` property of your client configuration ({LaunchDarkly::Config}).
21+
#
22+
# @param opts [Hash] the configuration options
23+
# @option opts [Hash] :consul_config an instance of `Diplomat::Configuration` to replace the default
24+
# Consul client configuration (note that this is exactly the same as modifying `Diplomat.configuration`)
25+
# @option opts [String] :prefix namespace prefix to add to all keys used by LaunchDarkly
26+
# @option opts [Logger] :logger a `Logger` instance; defaults to `Config.default_logger`
27+
# @option opts [Integer] :expiration_seconds (15) expiration time for the in-memory cache, in seconds; 0 for no local caching
28+
# @option opts [Integer] :capacity (1000) maximum number of items in the cache
29+
# @return [LaunchDarkly::Interfaces::FeatureStore] a feature store object
30+
#
31+
def self.new_feature_store(opts, &block)
32+
core = LaunchDarkly::Impl::Integrations::Consul::ConsulFeatureStoreCore.new(opts)
33+
return LaunchDarkly::Integrations::Util::CachingStoreWrapper.new(core, opts)
34+
end
35+
end
36+
end
37+
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
require "feature_store_spec_base"
2+
#require "diplomat"
3+
require "spec_helper"
4+
5+
6+
$my_prefix = 'testprefix'
7+
$null_log = ::Logger.new($stdout)
8+
$null_log.level = ::Logger::FATAL
9+
10+
$base_opts = {
11+
prefix: $my_prefix,
12+
logger: $null_log
13+
}
14+
15+
def create_consul_store(opts = {})
16+
LaunchDarkly::Integrations::Consul::new_feature_store(
17+
opts.merge($base_opts).merge({ expiration: 60 }))
18+
end
19+
20+
def create_consul_store_uncached(opts = {})
21+
LaunchDarkly::Integrations::Consul::new_feature_store(
22+
opts.merge($base_opts).merge({ expiration: 0 }))
23+
end
24+
25+
26+
describe "Consul feature store" do
27+
28+
# These tests will all fail if there isn't a local Consul instance running.
29+
30+
context "with local cache" do
31+
include_examples "feature_store", method(:create_consul_store)
32+
end
33+
34+
context "without local cache" do
35+
include_examples "feature_store", method(:create_consul_store_uncached)
36+
end
37+
end

0 commit comments

Comments
 (0)