Skip to content

Commit 81dcbf1

Browse files
authored
Merge pull request #86 from launchdarkly/eb/ch28328/dynamodb
DynamoDB feature store implementation
2 parents c62c49e + 69cf890 commit 81dcbf1

File tree

7 files changed

+375
-1
lines changed

7 files changed

+375
-1
lines changed

.circleci/config.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,26 +34,31 @@ jobs:
3434
docker:
3535
- image: circleci/ruby:2.2.9-jessie
3636
- image: redis
37+
- image: amazon/dynamodb-local
3738
test-2.3:
3839
<<: *ruby-docker-template
3940
docker:
4041
- image: circleci/ruby:2.3.6-jessie
4142
- image: redis
43+
- image: amazon/dynamodb-local
4244
test-2.4:
4345
<<: *ruby-docker-template
4446
docker:
4547
- image: circleci/ruby:2.4.4-stretch
4648
- image: redis
49+
- image: amazon/dynamodb-local
4750
test-2.5:
4851
<<: *ruby-docker-template
4952
docker:
5053
- image: circleci/ruby:2.5.1-stretch
5154
- image: redis
55+
- image: amazon/dynamodb-local
5256
test-jruby-9.2:
5357
<<: *ruby-docker-template
5458
docker:
5559
- image: circleci/jruby:9-jdk
5660
- image: redis
61+
- image: amazon/dynamodb-local
5762

5863
# The following very slow job uses an Ubuntu container to run the Ruby versions that
5964
# CircleCI doesn't provide Docker images for.
@@ -63,8 +68,11 @@ jobs:
6368
environment:
6469
- RUBIES: "jruby-9.1.17.0"
6570
steps:
71+
- run: sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
6672
- run: sudo apt-get -q update
6773
- run: sudo apt-get -qy install redis-server
74+
- run: sudo apt-cache policy docker-ce
75+
- run: sudo apt-get -qy install docker-ce
6876
- checkout
6977
- run:
7078
name: install all Ruby versions
@@ -84,6 +92,9 @@ jobs:
8492
bundle install;
8593
mv Gemfile.lock "Gemfile.lock.$i"
8694
done
95+
- run:
96+
command: docker run -p 8000:8000 amazon/dynamodb-local
97+
background: true
8798
- run:
8899
name: run tests for all versions
89100
shell: /bin/bash -leo pipefail

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ else
121121
end
122122
```
123123

124+
Database integrations
125+
---------------------
126+
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+
124129
Using flag data from a file
125130
---------------------------
126131
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.
@@ -153,9 +158,9 @@ About LaunchDarkly
153158
* [JavaScript](http://docs.launchdarkly.com/docs/js-sdk-reference "LaunchDarkly JavaScript SDK")
154159
* [PHP](http://docs.launchdarkly.com/docs/php-sdk-reference "LaunchDarkly PHP SDK")
155160
* [Python](http://docs.launchdarkly.com/docs/python-sdk-reference "LaunchDarkly Python SDK")
156-
* [Python Twisted](http://docs.launchdarkly.com/docs/python-twisted-sdk-reference "LaunchDarkly Python Twisted SDK")
157161
* [Go](http://docs.launchdarkly.com/docs/go-sdk-reference "LaunchDarkly Go SDK")
158162
* [Node.JS](http://docs.launchdarkly.com/docs/node-sdk-reference "LaunchDarkly Node SDK")
163+
* [Electron](http://docs.launchdarkly.com/docs/electron-sdk-reference "LaunchDarkly Electron SDK")
159164
* [.NET](http://docs.launchdarkly.com/docs/dotnet-sdk-reference "LaunchDarkly .Net SDK")
160165
* [Ruby](http://docs.launchdarkly.com/docs/ruby-sdk-reference "LaunchDarkly Ruby SDK")
161166
* [iOS](http://docs.launchdarkly.com/docs/ios-sdk-reference "LaunchDarkly iOS SDK")

ldclient-rb.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Gem::Specification.new do |spec|
2121
spec.require_paths = ["lib"]
2222
spec.extensions = 'ext/mkrf_conf.rb'
2323

24+
spec.add_development_dependency "aws-sdk-dynamodb", "~> 1.18"
2425
spec.add_development_dependency "bundler", "~> 1.7"
2526
spec.add_development_dependency "rspec", "~> 3.2"
2627
spec.add_development_dependency "codeclimate-test-reporter", "~> 0"
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
require "concurrent/atomics"
2+
require "json"
3+
4+
module LaunchDarkly
5+
module Impl
6+
module Integrations
7+
module DynamoDB
8+
#
9+
# Internal implementation of the DynamoDB feature store, intended to be used with CachingStoreWrapper.
10+
#
11+
class DynamoDBFeatureStoreCore
12+
begin
13+
require "aws-sdk-dynamodb"
14+
AWS_SDK_ENABLED = true
15+
rescue ScriptError, StandardError
16+
begin
17+
require "aws-sdk"
18+
AWS_SDK_ENABLED = true
19+
rescue ScriptError, StandardError
20+
AWS_SDK_ENABLED = false
21+
end
22+
end
23+
24+
PARTITION_KEY = "namespace"
25+
SORT_KEY = "key"
26+
27+
VERSION_ATTRIBUTE = "version"
28+
ITEM_JSON_ATTRIBUTE = "item"
29+
30+
def initialize(table_name, opts)
31+
if !AWS_SDK_ENABLED
32+
raise RuntimeError.new("can't use DynamoDB feature store without the aws-sdk or aws-sdk-dynamodb gem")
33+
end
34+
35+
@table_name = table_name
36+
@prefix = opts[:prefix]
37+
@logger = opts[:logger] || Config.default_logger
38+
39+
@stopped = Concurrent::AtomicBoolean.new(false)
40+
41+
if !opts[:existing_client].nil?
42+
@client = opts[:existing_client]
43+
else
44+
@client = Aws::DynamoDB::Client.new(opts[:dynamodb_opts])
45+
end
46+
47+
@logger.info("DynamoDBFeatureStore: using DynamoDB table \"#{table_name}\"")
48+
end
49+
50+
def init_internal(all_data)
51+
# Start by reading the existing keys; we will later delete any of these that weren't in all_data.
52+
unused_old_keys = read_existing_keys(all_data.keys)
53+
54+
requests = []
55+
num_items = 0
56+
57+
# Insert or update every provided item
58+
all_data.each do |kind, items|
59+
items.values.each do |item|
60+
requests.push({ put_request: { item: marshal_item(kind, item) } })
61+
unused_old_keys.delete([ namespace_for_kind(kind), item[:key] ])
62+
num_items = num_items + 1
63+
end
64+
end
65+
66+
# Now delete any previously existing items whose keys were not in the current data
67+
unused_old_keys.each do |tuple|
68+
del_item = make_keys_hash(tuple[0], tuple[1])
69+
requests.push({ delete_request: { key: del_item } })
70+
end
71+
72+
# Now set the special key that we check in initialized_internal?
73+
inited_item = make_keys_hash(inited_key, inited_key)
74+
requests.push({ put_request: { item: inited_item } })
75+
76+
DynamoDBUtil.batch_write_requests(@client, @table_name, requests)
77+
78+
@logger.info { "Initialized table #{@table_name} with #{num_items} items" }
79+
end
80+
81+
def get_internal(kind, key)
82+
resp = get_item_by_keys(namespace_for_kind(kind), key)
83+
unmarshal_item(resp.item)
84+
end
85+
86+
def get_all_internal(kind)
87+
items_out = {}
88+
req = make_query_for_kind(kind)
89+
while true
90+
resp = @client.query(req)
91+
resp.items.each do |item|
92+
item_out = unmarshal_item(item)
93+
items_out[item_out[:key].to_sym] = item_out
94+
end
95+
break if resp.last_evaluated_key.nil? || resp.last_evaluated_key.length == 0
96+
req.exclusive_start_key = resp.last_evaluated_key
97+
end
98+
items_out
99+
end
100+
101+
def upsert_internal(kind, new_item)
102+
encoded_item = marshal_item(kind, new_item)
103+
begin
104+
@client.put_item({
105+
table_name: @table_name,
106+
item: encoded_item,
107+
condition_expression: "attribute_not_exists(#namespace) or attribute_not_exists(#key) or :version > #version",
108+
expression_attribute_names: {
109+
"#namespace" => PARTITION_KEY,
110+
"#key" => SORT_KEY,
111+
"#version" => VERSION_ATTRIBUTE
112+
},
113+
expression_attribute_values: {
114+
":version" => new_item[:version]
115+
}
116+
})
117+
new_item
118+
rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
119+
# The item was not updated because there's a newer item in the database.
120+
# We must now read the item that's in the database and return it, so CachingStoreWrapper can cache it.
121+
get_internal(kind, new_item[:key])
122+
end
123+
end
124+
125+
def initialized_internal?
126+
resp = get_item_by_keys(inited_key, inited_key)
127+
!resp.item.nil? && resp.item.length > 0
128+
end
129+
130+
def stop
131+
# AWS client doesn't seem to have a close method
132+
end
133+
134+
private
135+
136+
def prefixed_namespace(base_str)
137+
(@prefix.nil? || @prefix == "") ? base_str : "#{@prefix}:#{base_str}"
138+
end
139+
140+
def namespace_for_kind(kind)
141+
prefixed_namespace(kind[:namespace])
142+
end
143+
144+
def inited_key
145+
prefixed_namespace("$inited")
146+
end
147+
148+
def make_keys_hash(namespace, key)
149+
{
150+
PARTITION_KEY => namespace,
151+
SORT_KEY => key
152+
}
153+
end
154+
155+
def make_query_for_kind(kind)
156+
{
157+
table_name: @table_name,
158+
consistent_read: true,
159+
key_conditions: {
160+
PARTITION_KEY => {
161+
comparison_operator: "EQ",
162+
attribute_value_list: [ namespace_for_kind(kind) ]
163+
}
164+
}
165+
}
166+
end
167+
168+
def get_item_by_keys(namespace, key)
169+
@client.get_item({
170+
table_name: @table_name,
171+
key: make_keys_hash(namespace, key)
172+
})
173+
end
174+
175+
def read_existing_keys(kinds)
176+
keys = Set.new
177+
kinds.each do |kind|
178+
req = make_query_for_kind(kind).merge({
179+
projection_expression: "#namespace, #key",
180+
expression_attribute_names: {
181+
"#namespace" => PARTITION_KEY,
182+
"#key" => SORT_KEY
183+
}
184+
})
185+
while true
186+
resp = @client.query(req)
187+
resp.items.each do |item|
188+
namespace = item[PARTITION_KEY]
189+
key = item[SORT_KEY]
190+
keys.add([ namespace, key ])
191+
end
192+
break if resp.last_evaluated_key.nil? || resp.last_evaluated_key.length == 0
193+
req.exclusive_start_key = resp.last_evaluated_key
194+
end
195+
end
196+
keys
197+
end
198+
199+
def marshal_item(kind, item)
200+
make_keys_hash(namespace_for_kind(kind), item[:key]).merge({
201+
VERSION_ATTRIBUTE => item[:version],
202+
ITEM_JSON_ATTRIBUTE => item.to_json
203+
})
204+
end
205+
206+
def unmarshal_item(item)
207+
return nil if item.nil? || item.length == 0
208+
json_attr = item[ITEM_JSON_ATTRIBUTE]
209+
raise RuntimeError.new("DynamoDB map did not contain expected item string") if json_attr.nil?
210+
JSON.parse(json_attr, symbolize_names: true)
211+
end
212+
end
213+
214+
class DynamoDBUtil
215+
#
216+
# Calls client.batch_write_item as many times as necessary to submit all of the given requests.
217+
# The requests array is consumed.
218+
#
219+
def self.batch_write_requests(client, table, requests)
220+
batch_size = 25
221+
while true
222+
chunk = requests.shift(batch_size)
223+
break if chunk.empty?
224+
client.batch_write_item({ request_items: { table => chunk } })
225+
end
226+
end
227+
end
228+
end
229+
end
230+
end
231+
end

lib/ldclient-rb/integrations.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
require "ldclient-rb/integrations/dynamodb"
12
require "ldclient-rb/integrations/redis"
23
require "ldclient-rb/integrations/util/store_wrapper"
34

@@ -6,9 +7,24 @@ module LaunchDarkly
67
# Tools for connecting the LaunchDarkly client to other software.
78
#
89
module Integrations
10+
#
11+
# Integration with [DynamoDB](https://aws.amazon.com/dynamodb/).
12+
#
13+
# Note that in order to use this integration, you must first install one of the AWS SDK gems: either
14+
# `aws-sdk-dynamodb`, or the full `aws-sdk`.
15+
#
16+
# @since 5.5.0
17+
#
18+
module DynamoDB
19+
# code is in ldclient-rb/impl/integrations/dynamodb_impl
20+
end
21+
922
#
1023
# Integration with [Redis](https://redis.io/).
1124
#
25+
# Note that in order to use this integration, you must first install the `redis` and `connection-pool`
26+
# gems.
27+
#
1228
# @since 5.5.0
1329
#
1430
module Redis
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
require "ldclient-rb/impl/integrations/dynamodb_impl"
2+
require "ldclient-rb/integrations/util/store_wrapper"
3+
4+
module LaunchDarkly
5+
module Integrations
6+
module DynamoDB
7+
#
8+
# Creates a DynamoDB-backed persistent feature store.
9+
#
10+
# To use this method, you must first install one of the AWS SDK gems: either `aws-sdk-dynamodb`, or
11+
# the full `aws-sdk`. Then, put the object returned by this method into the `feature_store` property
12+
# of your client configuration ({LaunchDarkly::Config}).
13+
#
14+
# @param opts [Hash] the configuration options
15+
# @option opts [Hash] :dynamodb_opts options to pass to the DynamoDB client constructor (ignored if you specify `:existing_client`)
16+
# @option opts [Object] :existing_client an already-constructed DynamoDB client for the feature store to use
17+
# @option opts [String] :prefix namespace prefix to add to all keys used by LaunchDarkly
18+
# @option opts [Logger] :logger a `Logger` instance; defaults to `Config.default_logger`
19+
# @option opts [Integer] :expiration_seconds (15) expiration time for the in-memory cache, in seconds; 0 for no local caching
20+
# @option opts [Integer] :capacity (1000) maximum number of items in the cache
21+
# @return [LaunchDarkly::Interfaces::FeatureStore] a feature store object
22+
#
23+
def self.new_feature_store(table_name, opts)
24+
core = LaunchDarkly::Impl::Integrations::DynamoDB::DynamoDBFeatureStoreCore.new(table_name, opts)
25+
return LaunchDarkly::Integrations::Util::CachingStoreWrapper.new(core, opts)
26+
end
27+
end
28+
end
29+
end

0 commit comments

Comments
 (0)