@@ -5,2 +5,18 @@ # Changelog | ||
## [0.4.0](https://github.com/escaletech/tog-node/compare/v0.3.6...v0.4.0) (2020-06-16) | ||
### ⚠ BREAKING CHANGES | ||
* flag keys have changed. | ||
### Features | ||
* adopt hash-based picking of rollouts ([54e559c](https://github.com/escaletech/tog-node/commit/54e559c74e7f109536c2c1c36cac2915a303e21e)) | ||
* adopt new flag format using hash map ([6556edd](https://github.com/escaletech/tog-node/commit/6556eddb3a835748ef5492b6f41a8b2c9b24b336)) | ||
* cease to store sessions ([eca88ec](https://github.com/escaletech/tog-node/commit/eca88ecdc58db1c9a95e8ce31b23998f714d21b9)) | ||
* implement cache for sessions ([d8c7949](https://github.com/escaletech/tog-node/commit/d8c794998564e057911549d17ec3958f7e380c6a)) | ||
* improve session client resilliency ([d1db2b1](https://github.com/escaletech/tog-node/commit/d1db2b10a5617b7ca97c9db1d400aa2bdc62dd01)) | ||
* remove duration from sessions ([53c755f](https://github.com/escaletech/tog-node/commit/53c755f2972c9a67915a34715ded4009ccbb7d8a)) | ||
### [0.3.6](https://github.com/escaletech/tog-node/compare/v0.3.5...v0.3.6) (2020-03-03) | ||
@@ -7,0 +23,0 @@ |
import { Flag, ClientOptions } from "./types"; | ||
import { Redis, Cluster } from 'ioredis'; | ||
import { Redis } from "./redis"; | ||
/** | ||
@@ -13,3 +13,3 @@ * A client for managing flags | ||
export declare class FlagClient { | ||
readonly redis: Redis | Cluster; | ||
readonly redis: Redis; | ||
/** | ||
@@ -42,6 +42,2 @@ * @param redisUrl The Redis connection string | ||
deleteFlag(namespace: string, name: string): Promise<boolean>; | ||
/** | ||
* @hidden | ||
*/ | ||
private getFlagByKey; | ||
} |
@@ -50,2 +50,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.FlagClient = void 0; | ||
var types_1 = require("./types"); | ||
@@ -79,24 +80,11 @@ var ioredis_1 = require("ioredis"); | ||
return __awaiter(this, void 0, void 0, function () { | ||
var keys, _a; | ||
var _this = this; | ||
return __generator(this, function (_b) { | ||
switch (_b.label) { | ||
case 0: | ||
if (!('nodes' in this.redis)) return [3 /*break*/, 2]; | ||
return [4 /*yield*/, Promise.all(this.redis.nodes('all').map(function (n) { return n.keys(keys_1.flagKey(namespace, '*')); })) | ||
.then(function (keys) { | ||
var _a; | ||
return Array.from(new Set((_a = []).concat.apply(_a, keys))); | ||
var records; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: return [4 /*yield*/, this.redis.hgetall(keys_1.namespaceKey(namespace))]; | ||
case 1: | ||
records = _a.sent(); | ||
return [2 /*return*/, Object.keys(records).sort().map(function (name) { | ||
return __assign({ namespace: namespace, name: name }, JSON.parse(records[name])); | ||
})]; | ||
case 1: | ||
_a = _b.sent(); | ||
return [3 /*break*/, 4]; | ||
case 2: return [4 /*yield*/, this.redis.keys(keys_1.flagKey(namespace, '*'))]; | ||
case 3: | ||
_a = _b.sent(); | ||
_b.label = 4; | ||
case 4: | ||
keys = _a; | ||
return [4 /*yield*/, Promise.all(keys.sort().map(function (key) { return _this.getFlagByKey(key); }))]; | ||
case 5: return [2 /*return*/, _b.sent()]; | ||
} | ||
@@ -113,4 +101,12 @@ }); | ||
return __awaiter(this, void 0, void 0, function () { | ||
var flag; | ||
return __generator(this, function (_a) { | ||
return [2 /*return*/, this.getFlagByKey(keys_1.flagKey(namespace, name))]; | ||
switch (_a.label) { | ||
case 0: return [4 /*yield*/, this.redis.hget(keys_1.namespaceKey(namespace), name)]; | ||
case 1: | ||
flag = _a.sent(); | ||
return [2 /*return*/, flag === null | ||
? Promise.reject(new types_1.FlagNotFoundError('flag not found')) | ||
: __assign({ namespace: namespace, name: name }, JSON.parse(flag))]; | ||
} | ||
}); | ||
@@ -131,7 +127,11 @@ }); | ||
description: flag.description, | ||
timestamp: Math.round((new Date()).getTime() / 1000), | ||
rollout: flag.rollout | ||
}; | ||
return [4 /*yield*/, this.redis.set(keys_1.flagKey(flag.namespace, flag.name), JSON.stringify(sanitized))]; | ||
return [4 /*yield*/, this.redis.hset(keys_1.namespaceKey(flag.namespace), flag.name, JSON.stringify(sanitized))]; | ||
case 1: | ||
_a.sent(); | ||
return [4 /*yield*/, this.redis.publish(keys_1.namespaceChangedKey, flag.namespace)]; | ||
case 2: | ||
_a.sent(); | ||
return [2 /*return*/, flag]; | ||
@@ -153,5 +153,8 @@ } | ||
switch (_a.label) { | ||
case 0: return [4 /*yield*/, this.redis.del(keys_1.flagKey(namespace, name))]; | ||
case 0: return [4 /*yield*/, this.redis.hdel(keys_1.namespaceKey(namespace), name)]; | ||
case 1: | ||
res = _a.sent(); | ||
return [4 /*yield*/, this.redis.publish(keys_1.namespaceChangedKey, namespace)]; | ||
case 2: | ||
_a.sent(); | ||
return [2 /*return*/, res > 0]; | ||
@@ -162,27 +165,5 @@ } | ||
}; | ||
/** | ||
* @hidden | ||
*/ | ||
FlagClient.prototype.getFlagByKey = function (key) { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var value; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: return [4 /*yield*/, this.redis.get(key)]; | ||
case 1: | ||
value = _a.sent(); | ||
return [2 /*return*/, value | ||
? parseFlag(key, value) | ||
: Promise.reject(new types_1.FlagNotFoundError('flag not found'))]; | ||
} | ||
}); | ||
}); | ||
}; | ||
return FlagClient; | ||
}()); | ||
exports.FlagClient = FlagClient; | ||
function parseFlag(key, value) { | ||
var _a = key.split(':'), namespace = _a[2], name = _a[3]; | ||
return __assign({ namespace: namespace, name: name }, JSON.parse(value)); | ||
} | ||
//# sourceMappingURL=flagClient.js.map |
"use strict"; | ||
function __export(m) { | ||
for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; | ||
} | ||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { | ||
if (k2 === undefined) k2 = k; | ||
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); | ||
}) : (function(o, m, k, k2) { | ||
if (k2 === undefined) k2 = k; | ||
o[k2] = m[k]; | ||
})); | ||
var __exportStar = (this && this.__exportStar) || function(m, exports) { | ||
for (var p in m) if (p !== "default" && !exports.hasOwnProperty(p)) __createBinding(exports, m, p); | ||
}; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
__export(require("./types")); | ||
__export(require("./flagClient")); | ||
__export(require("./sessionClient")); | ||
__exportStar(require("./types"), exports); | ||
__exportStar(require("./flagClient"), exports); | ||
__exportStar(require("./sessionClient"), exports); | ||
//# sourceMappingURL=index.js.map |
@@ -1,2 +0,2 @@ | ||
export declare function flagKey(namespace: string, name: string): string; | ||
export declare function sessionKey(namespace: string, id: string): string; | ||
export declare const namespaceChangedKey = "tog3:namespace-changed"; | ||
export declare function namespaceKey(namespace: string): string; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
function flagKey(namespace, name) { | ||
return "tog2:flag:" + namespace + ":" + name; | ||
exports.namespaceKey = exports.namespaceChangedKey = void 0; | ||
exports.namespaceChangedKey = 'tog3:namespace-changed'; | ||
function namespaceKey(namespace) { | ||
return "tog3:flags:" + namespace; | ||
} | ||
exports.flagKey = flagKey; | ||
function sessionKey(namespace, id) { | ||
return "tog2:session:" + namespace + ":" + id; | ||
} | ||
exports.sessionKey = sessionKey; | ||
exports.namespaceKey = namespaceKey; | ||
//# sourceMappingURL=keys.js.map |
@@ -1,3 +0,3 @@ | ||
import { Session, SessionOptions, ClientOptions } from "./types"; | ||
import { Redis, Cluster } from 'ioredis'; | ||
import { Flag, Session, SessionOptions, SessionClientOptions } from "./types"; | ||
import { Redis } from "./redis"; | ||
/** | ||
@@ -13,8 +13,14 @@ * A client consuming sessions | ||
export declare class SessionClient { | ||
private readonly options; | ||
private readonly logger; | ||
private readonly flags; | ||
readonly redis: Redis | Cluster; | ||
readonly redis: Redis; | ||
readonly subscriber: Redis; | ||
readonly cache: { | ||
[namespace: string]: Promise<Flag[]>; | ||
}; | ||
/** | ||
* @param redisUrl The Redis connection string | ||
*/ | ||
constructor(redisUrl: string, options?: ClientOptions); | ||
constructor(redisUrl: string, options?: SessionClientOptions); | ||
/** | ||
@@ -26,11 +32,5 @@ * Resolves a session, either by retrieving it or by computing a new one | ||
*/ | ||
session(namespace: string, id: string, options: SessionOptions): Promise<Session>; | ||
/** | ||
* @hidden | ||
*/ | ||
private createSession; | ||
/** | ||
* @hidden | ||
*/ | ||
private saveSession; | ||
session(namespace: string, id: string, options?: SessionOptions): Promise<Session>; | ||
private clearCache; | ||
private listFlags; | ||
} |
@@ -50,5 +50,9 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.SessionClient = void 0; | ||
var ioredis_1 = require("ioredis"); | ||
var sessions_1 = require("./sessions"); | ||
var flagClient_1 = require("./flagClient"); | ||
var logger_1 = require("./logger"); | ||
var keys_1 = require("./keys"); | ||
var flagClient_1 = require("./flagClient"); | ||
var DEFAULT_TIMEOUT = 300; | ||
/** | ||
@@ -68,5 +72,14 @@ * A client consuming sessions | ||
function SessionClient(redisUrl, options) { | ||
var _this = this; | ||
if (options === void 0) { options = {}; } | ||
this.options = options; | ||
this.logger = options.logger || logger_1.defaultLogger; | ||
this.flags = new flagClient_1.FlagClient(redisUrl, options); | ||
this.redis = this.flags.redis; | ||
this.cache = {}; | ||
this.subscriber = options.cluster | ||
? new ioredis_1.default.Cluster([redisUrl]) | ||
: new ioredis_1.default(redisUrl); | ||
this.subscriber.subscribe(keys_1.namespaceChangedKey); | ||
this.subscriber.on('message', function (key, namespace) { return _this.clearCache(namespace); }); | ||
} | ||
@@ -81,33 +94,16 @@ /** | ||
return __awaiter(this, void 0, void 0, function () { | ||
var key, value; | ||
var flagOverrides, availableFlags, flags, session, e_1; | ||
var _this = this; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
key = keys_1.sessionKey(namespace, id); | ||
return [4 /*yield*/, this.redis.get(key)]; | ||
case 1: | ||
value = _a.sent(); | ||
return [2 /*return*/, value | ||
? sessions_1.parseSession(namespace, id, value) | ||
: this.createSession(namespace, id, options)]; | ||
} | ||
}); | ||
}); | ||
}; | ||
/** | ||
* @hidden | ||
*/ | ||
SessionClient.prototype.createSession = function (namespace, id, options) { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var flagOverrides, flags, session; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
_a.trys.push([0, 2, , 3]); | ||
flagOverrides = options && options.flags || {}; | ||
return [4 /*yield*/, this.flags.listFlags(namespace)]; | ||
return [4 /*yield*/, withTimeout(this.options.timeout || DEFAULT_TIMEOUT, function () { return _this.listFlags(namespace); })]; | ||
case 1: | ||
flags = (_a.sent()) | ||
availableFlags = _a.sent(); | ||
flags = availableFlags | ||
.reduce(function (all, flag) { | ||
var _a; | ||
return (__assign(__assign({}, all), (_a = {}, _a[flag.name] = sessions_1.resolveState(flag.rollout), _a))); | ||
return (__assign(__assign({}, all), (_a = {}, _a[flag.name] = sessions_1.resolveState(flag.rollout, flag.timestamp || 0, id), _a))); | ||
}, {}); | ||
@@ -119,8 +115,8 @@ session = { | ||
}; | ||
if (!(Object.keys(session.flags).length > 0)) return [3 /*break*/, 3]; | ||
return [4 /*yield*/, this.saveSession(session, options.duration || 86400)]; | ||
return [2 /*return*/, session]; | ||
case 2: | ||
_a.sent(); | ||
_a.label = 3; | ||
case 3: return [2 /*return*/, session]; | ||
e_1 = _a.sent(); | ||
this.logger.error(e_1); | ||
return [2 /*return*/, { namespace: namespace, id: id, flags: {} }]; | ||
case 3: return [2 /*return*/]; | ||
} | ||
@@ -130,23 +126,28 @@ }); | ||
}; | ||
/** | ||
* @hidden | ||
*/ | ||
SessionClient.prototype.saveSession = function (session, duration) { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var key; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
key = keys_1.sessionKey(session.namespace, session.id); | ||
return [4 /*yield*/, this.redis.set(key, JSON.stringify(session.flags), 'EX', duration)]; | ||
case 1: | ||
_a.sent(); | ||
return [2 /*return*/]; | ||
} | ||
}); | ||
}); | ||
SessionClient.prototype.clearCache = function (namespace) { | ||
this.logger.info("namespace " + namespace + " has changed"); | ||
delete (this.cache[namespace]); | ||
}; | ||
SessionClient.prototype.listFlags = function (namespace) { | ||
return this.cache[namespace] || (this.cache[namespace] = this.flags.listFlags(namespace)); | ||
}; | ||
return SessionClient; | ||
}()); | ||
exports.SessionClient = SessionClient; | ||
/** | ||
* @hidden | ||
*/ | ||
function withTimeout(durationMs, promise) { | ||
var timeout; | ||
var timeoutPromise = new Promise(function (resolve, reject) { | ||
timeout = setTimeout(function () { return reject(new Error("timeout after " + durationMs + "ms")); }, durationMs); | ||
}); | ||
return Promise.race([ | ||
promise().then(function (res) { | ||
clearTimeout(timeout); | ||
return res; | ||
}), | ||
timeoutPromise | ||
]); | ||
} | ||
//# sourceMappingURL=sessionClient.js.map |
import { Rollout, Session } from './types'; | ||
export declare function parseSession(namespace: string, id: string, value: string): Session; | ||
export declare function resolveState(rollouts: Rollout[]): boolean; | ||
export declare function resolveState(rollouts: Rollout[], timestamp: number, sessionId: string): boolean; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.resolveState = exports.parseSession = void 0; | ||
var murmurhash_js_1 = require("murmurhash-js"); | ||
function parseSession(namespace, id, value) { | ||
@@ -11,10 +13,11 @@ return { | ||
exports.parseSession = parseSession; | ||
function resolveState(rollouts) { | ||
function resolveState(rollouts, timestamp, sessionId) { | ||
if (!rollouts || rollouts.length === 0) { | ||
return false; | ||
} | ||
var param = murmurhash_js_1.default.murmur3("" + sessionId + timestamp) % 100; | ||
var rollout = rollouts.find(function (r) { | ||
return r.percentage !== undefined | ||
? Math.floor(Math.random() * 99) + 1 <= r.percentage | ||
: r.value; | ||
return r.percentage === undefined | ||
? r.value | ||
: param <= r.percentage; | ||
}); | ||
@@ -21,0 +24,0 @@ return (rollout && rollout.value) || false; |
@@ -0,1 +1,2 @@ | ||
import { Logger } from "./logger"; | ||
/** A flag's specification */ | ||
@@ -9,2 +10,4 @@ export interface Flag { | ||
description?: string; | ||
/** UNIX timestamp of when the flag was last changed */ | ||
timestamp?: number; | ||
/** Specification of how the flag's value is computed for new sessions */ | ||
@@ -39,4 +42,2 @@ rollout: Rollout[]; | ||
export interface SessionOptions { | ||
/** Number of seconds for which the session should last */ | ||
duration?: number; | ||
/** Flag values that should be overridden */ | ||
@@ -52,2 +53,9 @@ flags?: Flags; | ||
} | ||
/** | ||
* Options for creating session clients | ||
*/ | ||
export interface SessionClientOptions extends ClientOptions { | ||
timeout?: number; | ||
logger?: Logger; | ||
} | ||
/** Error that is thrown when a flag cannot be found */ | ||
@@ -54,0 +62,0 @@ export declare class FlagNotFoundError extends Error { |
@@ -16,2 +16,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.FlagNotFoundError = void 0; | ||
/** Error that is thrown when a flag cannot be found */ | ||
@@ -18,0 +19,0 @@ var FlagNotFoundError = /** @class */ (function (_super) { |
{ | ||
"name": "tog-node", | ||
"version": "0.3.6", | ||
"version": "0.4.0", | ||
"description": "", | ||
@@ -20,16 +20,18 @@ "main": "lib/index.js", | ||
"dependencies": { | ||
"ioredis": "^4.16.0" | ||
"ioredis": "^4.17.3", | ||
"murmurhash-js": "^1.0.0" | ||
}, | ||
"devDependencies": { | ||
"@babel/core": "^7.8.6", | ||
"@babel/preset-env": "^7.8.6", | ||
"@babel/preset-typescript": "^7.8.3", | ||
"@types/ioredis": "^4.14.8", | ||
"@types/jest": "^24.0.15", | ||
"@types/redis": "^2.8.16", | ||
"babel-jest": "^25.1.0", | ||
"jest": "^24.8.0", | ||
"@babel/core": "^7.10.2", | ||
"@babel/preset-env": "^7.10.2", | ||
"@babel/preset-typescript": "^7.10.1", | ||
"@types/ioredis": "^4.16.5", | ||
"@types/jest": "^24.9.1", | ||
"@types/murmurhash-js": "^1.0.3", | ||
"babel-jest": "^25.5.1", | ||
"jest": "^24.9.0", | ||
"standard-version": "^7.1.0", | ||
"typedoc": "^0.17.0-3", | ||
"typescript": "^3.8.2" | ||
"typedoc": "^0.17.7", | ||
"typescript": "^3.9.5", | ||
"wait-for-expect": "^3.0.2" | ||
}, | ||
@@ -36,0 +38,0 @@ "babel": { |
@@ -23,3 +23,3 @@ # Tog Node.js Client | ||
// wherever you whish to retrieve a session | ||
const session = await sessions.session('my_app', 'session-123-xyz', { duration: 60 }) | ||
const session = await sessions.session('my_app', 'session-123-xyz') | ||
@@ -26,0 +26,0 @@ const buttonColor = session.flags['blue-button'] ? 'blue' : 'red' |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
36021
8.95%28
27.27%554
3.94%2
100%12
9.09%