Skip to content
This repository was archived by the owner on Nov 18, 2024. It is now read-only.

Commit ee7a8c2

Browse files
authored
initial implementation of Consul feature store (#1)
1 parent 3a6f4ef commit ee7a8c2

File tree

14 files changed

+536
-0
lines changed

14 files changed

+536
-0
lines changed

.babelrc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"env": {
3+
"test": {
4+
"presets": [
5+
[
6+
"env",
7+
{
8+
"targets": {
9+
"node": "6"
10+
}
11+
}
12+
]
13+
]
14+
}
15+
}
16+
}

.circleci/config.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
version: 2
2+
3+
workflows:
4+
version: 2
5+
test:
6+
jobs:
7+
- oldest-long-term-support-release
8+
- current-release
9+
10+
node-template: &node-template
11+
steps:
12+
- checkout
13+
- run: echo "Node version:" `node --version`
14+
- run: npm install
15+
- run: npm run lint
16+
- run:
17+
command: npm test
18+
environment:
19+
JEST_JUNIT_OUTPUT: "reports/junit/js-test-results.xml"
20+
- store_test_results:
21+
path: reports/junit
22+
- run: npm run check-typescript
23+
- store_artifacts:
24+
path: reports/junit
25+
26+
jobs:
27+
oldest-long-term-support-release:
28+
<<: *node-template
29+
docker:
30+
- image: circleci/node:6
31+
- image: consul
32+
33+
current-release:
34+
<<: *node-template
35+
docker:
36+
- image: circleci/node:latest
37+
- image: consul

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/

.eslintrc.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module.exports = {
2+
"env": {
3+
"node": true,
4+
"es6": true
5+
},
6+
"extends": "eslint:recommended",
7+
"rules": {
8+
"indent": [
9+
"error",
10+
2
11+
],
12+
"linebreak-style": [
13+
"error",
14+
"unix"
15+
],
16+
"quotes": [
17+
"error",
18+
"single"
19+
],
20+
"semi": [
21+
"error",
22+
"always"
23+
]
24+
}
25+
};

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Change log
2+
3+
All notable changes to the LaunchDarkly Node.js SDK Consul integration will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org).
4+
5+
## [1.0.0] - 2019-01-11
6+
7+
Initial release.
8+

LICENSE

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Copyright 2018 Catamorphic, Co.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
LaunchDarkly SDK for Node.js - Consul integration
2+
=================================================
3+
[![CircleCI](https://circleci.com/gh/launchdarkly/node-consul-store.svg?style=svg)](https://circleci.com/gh/launchdarkly/node-consul-store)
4+
5+
This library provides a Consul-backed persistence mechanism (feature store) for the [LaunchDarkly Node.js SDK](https://github.com/launchdarkly/node-client), replacing the default in-memory feature store. It uses the [consul](https://www.npmjs.com/package/consul) package.
6+
7+
The minimum version of the LaunchDarkly Node.js SDK for use with this library is 5.7.0.
8+
9+
For more information, see also: [Using a persistent feature store](https://docs.launchdarkly.com/v2.0/docs/using-a-persistent-feature-store).
10+
11+
Quick setup
12+
-----------
13+
14+
This assumes that you have already installed the LaunchDarkly Node.js SDK.
15+
16+
1. Install this package with `npm`:
17+
18+
npm install ldclient-node-consul-store --save
19+
20+
2. Require the package:
21+
22+
var ConsulFeatureStore = require('ldclient-node-consul-store');
23+
24+
3. When configuring your SDK client, add the Consul feature store:
25+
26+
var store = ConsulFeatureStore({ consulOptions: { host: 'your-consul-host' } });
27+
var config = { featureStore: store };
28+
var client = LaunchDarkly.init('YOUR SDK KEY', config);
29+
30+
4. If you are running a [LaunchDarkly Relay Proxy](https://github.com/launchdarkly/ld-relay) instance, or any other process that will prepopulate Consul with feature flags from LaunchDarkly, you can use [daemon mode](https://github.com/launchdarkly/ld-relay#daemon-mode), so that the SDK retrieves flag data only from Consul and does not communicate directly with LaunchDarkly. This is controlled by the SDK's `useLdd` option:
31+
32+
var config = { featureStore: store, useLdd: true };
33+
var client = LaunchDarkly.init('YOUR SDK KEY', config);
34+
35+
5. If the same Consul host is being shared by SDK clients for different LaunchDarkly environments, set the `prefix` option to a different short string for each one to keep the keys from colliding:
36+
37+
var store = ConsulFeatureStore({ consulOptions: { host: 'your-consul-host' }, prefix: 'env1' });
38+
39+
Caching behavior
40+
----------------
41+
42+
To reduce traffic to Consul, there is an optional in-memory cache that retains the last known data for a configurable amount of time. This is on by default; to turn it off (and guarantee that the latest feature flag data will always be retrieved from Consul for every flag evaluation), configure the store as follows:
43+
44+
var store = ConsulFeatureStore('YOUR TABLE NAME', { cacheTTL: 0 });
45+
46+
About LaunchDarkly
47+
------------------
48+
49+
* 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:
50+
* 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.
51+
* Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?).
52+
* Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file.
53+
* Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline.
54+
* LaunchDarkly provides feature flag SDKs for
55+
* [Java](http://docs.launchdarkly.com/docs/java-sdk-reference "Java SDK")
56+
* [JavaScript](http://docs.launchdarkly.com/docs/js-sdk-reference "LaunchDarkly JavaScript SDK")
57+
* [PHP](http://docs.launchdarkly.com/docs/php-sdk-reference "LaunchDarkly PHP SDK")
58+
* [Python](http://docs.launchdarkly.com/docs/python-sdk-reference "LaunchDarkly Python SDK")
59+
* [Go](http://docs.launchdarkly.com/docs/go-sdk-reference "LaunchDarkly Go SDK")
60+
* [Node.JS](http://docs.launchdarkly.com/docs/node-sdk-reference "LaunchDarkly Node SDK")
61+
* [Electron](http://docs.launchdarkly.com/docs/electron-sdk-reference "LaunchDarkly Electron SDK")
62+
* [.NET](http://docs.launchdarkly.com/docs/dotnet-sdk-reference "LaunchDarkly .Net SDK")
63+
* [Ruby](http://docs.launchdarkly.com/docs/ruby-sdk-reference "LaunchDarkly Ruby SDK")
64+
* [iOS](http://docs.launchdarkly.com/docs/ios-sdk-reference "LaunchDarkly iOS SDK")
65+
* [Android](http://docs.launchdarkly.com/docs/android-sdk-reference "LaunchDarkly Android SDK")
66+
* Explore LaunchDarkly
67+
* [launchdarkly.com](http://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information
68+
* [docs.launchdarkly.com](http://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDKs
69+
* [apidocs.launchdarkly.com](http://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation
70+
* [blog.launchdarkly.com](http://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates
71+
* [Feature Flagging Guide](https://github.com/launchdarkly/featureflags/ "Feature Flagging Guide") for best practices and strategies

consul_feature_store.js

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
var consul = require('consul');
2+
var winston = require('winston');
3+
4+
var CachingStoreWrapper = require('ldclient-node/caching_store_wrapper');
5+
6+
var defaultCacheTTLSeconds = 15;
7+
var defaultPrefix = 'launchdarkly';
8+
var notFoundError = 'not found'; // unfortunately the Consul client doesn't have error classes or codes
9+
10+
function ConsulFeatureStore(options) {
11+
var ttl = options && options.cacheTTL;
12+
if (ttl === null || ttl === undefined) {
13+
ttl = defaultCacheTTLSeconds;
14+
}
15+
return new CachingStoreWrapper(consulFeatureStoreInternal(options), ttl);
16+
}
17+
18+
function consulFeatureStoreInternal(options) {
19+
options = options || {};
20+
var logger = (options.logger ||
21+
new winston.Logger({
22+
level: 'info',
23+
transports: [
24+
new (winston.transports.Console)(),
25+
]
26+
})
27+
);
28+
var client = consul(Object.assign({}, options.consulOptions, { promisify: true }));
29+
// Note, "promisify: true" causes the client to decorate all of its methods so they return Promises
30+
// instead of taking callbacks. That's the reason why we can't let the caller pass an already-created
31+
// client to us - because our code wouldn't work if it wasn't in Promise mode.
32+
var prefix = (options.prefix || defaultPrefix) + '/';
33+
34+
var store = {};
35+
36+
function kindKey(kind) {
37+
return prefix + kind.namespace + '/';
38+
}
39+
40+
function itemKey(kind, key) {
41+
return kindKey(kind) + key;
42+
}
43+
44+
function initedKey() {
45+
return prefix + '$inited';
46+
}
47+
48+
// The issue here is that this Consul client is very literal-minded about what is an error, so if Consul
49+
// returns a 404, it treats that as a failed operation rather than just "the query didn't return anything."
50+
function suppressNotFoundErrors(promise) {
51+
return promise.catch(function(err) {
52+
if (err.message == notFoundError) {
53+
return Promise.resolve();
54+
}
55+
});
56+
}
57+
58+
function logError(err, actionDesc) {
59+
logger.error('Consul error on ' + actionDesc + ': ' + err);
60+
}
61+
62+
function errorHandler(cb, failValue, message) {
63+
return function(err) {
64+
logError(err, message);
65+
cb(failValue);
66+
};
67+
}
68+
69+
store.getInternal = function(kind, key, cb) {
70+
suppressNotFoundErrors(client.kv.get({ key: itemKey(kind, key) }))
71+
.then(function(result) {
72+
cb(result ? JSON.parse(result.Value) : null);
73+
})
74+
.catch(errorHandler(cb, null, 'query of ' + kind.namespace + ' ' + key));
75+
};
76+
77+
store.getAllInternal = function(kind, cb) {
78+
suppressNotFoundErrors(client.kv.get({ key: kindKey(kind), recurse: true }))
79+
.then(function(result) {
80+
var itemsOut = {};
81+
if (result) {
82+
result.forEach(function(value) {
83+
var item = JSON.parse(value.Value);
84+
itemsOut[item.key] = item;
85+
});
86+
}
87+
cb(itemsOut);
88+
})
89+
.catch(errorHandler(cb, {}, 'query of all ' + kind.namespace));
90+
};
91+
92+
store.initOrderedInternal = function(allData, cb) {
93+
suppressNotFoundErrors(client.kv.keys({ key: prefix }))
94+
.then(function(keys) {
95+
var oldKeys = new Set(keys || []);
96+
oldKeys.delete(initedKey());
97+
98+
// Write all initial data (without version checks). Note that on other platforms, we batch
99+
// these operations using the KV.txn endpoint, but the Node Consul client doesn't support that.
100+
var promises = [];
101+
allData.forEach(function(collection) {
102+
var kind = collection.kind;
103+
collection.items.forEach(function(item) {
104+
var key = itemKey(kind, item.key);
105+
oldKeys.delete(key);
106+
var op = client.kv.set({ key: key, value: JSON.stringify(item) });
107+
promises.push(op);
108+
});
109+
});
110+
111+
// Remove existing data that is not in the new list.
112+
oldKeys.forEach(function(key) {
113+
var op = client.kv.del({ key: key });
114+
promises.push(op);
115+
});
116+
117+
// Always write the initialized token when we initialize.
118+
var op = client.kv.set({ key: initedKey(), value: '' });
119+
promises.push(op);
120+
121+
return Promise.all(promises);
122+
})
123+
.then(function() { cb(); })
124+
.catch(errorHandler(cb, null, 'init'));
125+
};
126+
127+
store.upsertInternal = function(kind, newItem, cb) {
128+
var key = itemKey(kind, newItem.key);
129+
var json = JSON.stringify(newItem);
130+
131+
var tryUpdate = function() {
132+
return suppressNotFoundErrors(client.kv.get({ key: key }))
133+
.then(function(oldValue) {
134+
// instrumentation for unit tests
135+
if (store.testUpdateHook) {
136+
return new Promise(store.testUpdateHook).then(function() { return oldValue; });
137+
} else {
138+
return oldValue;
139+
}
140+
})
141+
.then(function(oldValue) {
142+
var oldItem = oldValue && JSON.parse(oldValue.Value);
143+
144+
// Check whether the item is stale. If so, don't do the update (and return the existing item to
145+
// FeatureStoreWrapper so it can be cached)
146+
if (oldItem && oldItem.version >= newItem.version) {
147+
return oldItem;
148+
}
149+
150+
// Otherwise, try to write. We will do a compare-and-set operation, so the write will only succeed if
151+
// the key's ModifyIndex is still equal to the previous value returned by getEvenIfDeleted. If the
152+
// previous ModifyIndex was zero, it means the key did not previously exist and the write will only
153+
// succeed if it still doesn't exist.
154+
var modifyIndex = oldValue ? oldValue.ModifyIndex : 0;
155+
var p = client.kv.set({ key: key, value: json, cas: modifyIndex });
156+
return p.then(function(result) {
157+
return result ? newItem : tryUpdate(); // start over if the compare-and-set failed
158+
});
159+
});
160+
};
161+
162+
tryUpdate().then(
163+
function(result) { cb(null, result); },
164+
function(err) {
165+
logger.error('failed to update: ' + err);
166+
cb(err, null);
167+
});
168+
};
169+
170+
store.initializedInternal = function(cb) {
171+
suppressNotFoundErrors(client.kv.get({ key: initedKey() }))
172+
.then(function(result) { cb(!!result); })
173+
.catch(errorHandler(cb, false, 'initialized check'));
174+
};
175+
176+
store.close = function() {
177+
// nothing to do here
178+
};
179+
180+
return store;
181+
}
182+
183+
module.exports = ConsulFeatureStore;

0 commit comments

Comments
 (0)