Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redis-based StickyBucketService for remote eval #48

Merged
merged 4 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ The GrowthBook Proxy repository is a mono-repo containing the following packages

### What's new

**Version 1.1.4**
- Support Redis-based sticky bucketing for remote evaluation
- Update remote evaluation to allow for buffered sticky bucket writes
- Update SDK version to support sticky bucketing and prerequisite flags

**Version 1.1.2**
- Fix max payload size bug
- Deprecate `CLUSTER_ROOT_NODES` in favor of `CLUSTER_ROOT_NODES_JSON`
Expand Down Expand Up @@ -149,6 +154,12 @@ If the GrowthBook app your proxy is connecting to is using a self-signed certifi

**Remote Evaluation**
- `ENABLE_REMOTE_EVAL` - "true" or "1" to enable remote evaluation (default: `true`)
- `ENABLE_STICKY_BUCKETING` - "true" or "1" to enable sticky bucketing for remote evaluation. Requires a Redis connection (default: `false`)
- `STICKY_BUCKET_ENGINE` - One of: `redis`, `none` (only Redis is supported) (default: `none`)
- `STICKY_BUCKET_CONNECTION_URL` - The URL of your Redis Database
- `STICKY_BUCKET_USE_CLUSTER` - "true" or "1" to enable Redis cluster mode (default: `false`)
- `STICKY_BUCKET_CLUSTER_ROOT_NODES_JSON` - JSON array of ClusterNode objects (ioredis)
- `STICKY_BUCKET_CLUSTER_OPTIONS_JSON` - JSON object of ClusterOptions (ioredis)

**Misc**
- `MAX_PAYLOAD_SIZE` - The maximum size of a request body (default: `"2mb"`)
Expand Down
5 changes: 3 additions & 2 deletions packages/apps/proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"pino-http": "^8.3.1",
"spdy": "^4.0.2",
"uuid": "^9.0.0",
"@growthbook/proxy-eval": "^1.0.0"
"@growthbook/proxy-eval": "^1.0.2"
},
"devDependencies": {
"@types/cors": "^2.8.14",
Expand All @@ -44,6 +44,7 @@
"nodemon": "^3.0.1",
"pino-pretty": "^10.3.1",
"rimraf": "^5.0.5",
"typescript": "5.2.2"
"typescript": "5.2.2",
"@growthbook/growthbook": "^0.34.0"
}
}
5 changes: 5 additions & 0 deletions packages/apps/proxy/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "./services/eventStreamManager";
import { Context, GrowthBookProxy } from "./types";
import logger, { initializeLogger } from "./services/logger";
import { initializeStickyBucketService } from "./services/stickyBucket";

export { Context, GrowthBookProxy, CacheEngine } from "./types";

Expand Down Expand Up @@ -57,6 +58,7 @@ const defaultContext: Context = {
enableEventStream: true,
enableEventStreamHeaders: true,
enableRemoteEval: true,
enableStickyBucketing: false,
proxyAllRequests: false,
};

Expand All @@ -76,6 +78,9 @@ export const growthBookProxy = async (
initializeLogger(ctx);
await initializeRegistrar(ctx);
ctx.enableCache && (await initializeCache(ctx));
ctx.enableRemoteEval &&
ctx.enableStickyBucketing &&
(await initializeStickyBucketService(ctx));
ctx.enableEventStream && initializeEventStreamManager(ctx);

// set up handlers
Expand Down
4 changes: 3 additions & 1 deletion packages/apps/proxy/src/controllers/featuresController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import express, { NextFunction, Request, Response } from "express";
import { evaluateFeatures } from "@growthbook/proxy-eval";
import readThroughCacheMiddleware from "../middleware/cache/readThroughCacheMiddleware";
import { featuresCache } from "../services/cache";
import { stickyBucketService } from "../services/stickyBucket";
import { registrar } from "../services/registrar";
import { apiKeyMiddleware } from "../middleware/apiKeyMiddleware";
import webhookVerificationMiddleware from "../middleware/webhookVerificationMiddleware";
Expand Down Expand Up @@ -144,12 +145,13 @@ const getEvaluatedFeatures = async (req: Request, res: Response) => {
);
const url = req.body?.url;

payload = evaluateFeatures({
payload = await evaluateFeatures({
payload,
attributes,
forcedVariations,
forcedFeatures,
url,
stickyBucketService,
ctx: req.app.locals?.ctx,
});

Expand Down
21 changes: 20 additions & 1 deletion packages/apps/proxy/src/init.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import express from "express";
import * as spdy from "spdy";
import dotenv from "dotenv";
import { CacheEngine, Context } from "./types";
import { CacheEngine, Context, StickyBucketEngine } from "./types";
dotenv.config({ path: "./.env.local" });

export const MAX_PAYLOAD_SIZE = "2mb";
Expand Down Expand Up @@ -72,6 +72,25 @@ export default async () => {
enableRemoteEval: ["true", "1"].includes(
process.env.ENABLE_REMOTE_EVAL ?? "1",
),
// Sticky Bucket settings (for remote eval):
enableStickyBucketing: ["true", "1"].includes(
process.env.ENABLE_STICKY_BUCKETING ?? "0",
),
stickyBucketSettings: {
engine: (process.env.STICKY_BUCKET_ENGINE ||
"none") as StickyBucketEngine,
connectionUrl: process.env.STICKY_BUCKET_CONNECTION_URL,
// Redis only - cluster:
useCluster: ["true", "1"].includes(
process.env.STICKY_BUCKET_USE_CLUSTER ?? "0",
),
clusterRootNodesJSON: process.env.STICKY_BUCKET_CLUSTER_ROOT_NODES_JSON
? JSON.parse(process.env.STICKY_BUCKET_CLUSTER_ROOT_NODES_JSON)
: undefined,
clusterOptionsJSON: process.env.STICKY_BUCKET_CLUSTER_OPTIONS_JSON
? JSON.parse(process.env.STICKY_BUCKET_CLUSTER_OPTIONS_JSON)
: undefined,
},
};

// Express configuration consts:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Redis, { Cluster, ClusterNode, ClusterOptions } from "ioredis";

import { StickyBucketService } from "@growthbook/growthbook";
import logger from "../logger";
import { StickyBucketSettings } from "./index";

// from JS SDK (todo: export from sdk):
export type StickyAttributeKey = string; // `${attributeName}||${attributeValue}`
export type StickyExperimentKey = string; // `${experimentId}__{version}`
export type StickyAssignments = Record<StickyExperimentKey, string>;
export interface StickyAssignmentsDocument {
attributeName: string;
attributeValue: string;
assignments: StickyAssignments;
}

// Mostly taken from the JS SDK, with write buffer for when remote eval finishes

export class RedisStickyBucketService extends StickyBucketService {
private client: Redis | Cluster | undefined;
private readonly connectionUrl: string | undefined;
private readonly useCluster: boolean;
private readonly clusterRootNodesJSON?: ClusterNode[];
private readonly clusterOptions?: ClusterOptions;

private writeBuffer: Record<string, string> = {};

public constructor({
connectionUrl,
useCluster = false,
clusterRootNodesJSON,
clusterOptionsJSON,
}: StickyBucketSettings = {}) {
super();
this.connectionUrl = connectionUrl;
this.useCluster = useCluster;
this.clusterRootNodesJSON = clusterRootNodesJSON;
this.clusterOptions = clusterOptionsJSON;
}

public async connect() {
if (!this.useCluster) {
this.client = this.connectionUrl
? new Redis(this.connectionUrl)
: new Redis();
} else {
if (this.clusterRootNodesJSON) {
this.client = new Redis.Cluster(
this.clusterRootNodesJSON,
this.clusterOptions,
);
} else {
throw new Error("No cluster root nodes");
}
}
}

public async getAllAssignments(
attributes: Record<string, string>,
): Promise<Record<StickyAttributeKey, StickyAssignmentsDocument>> {
const docs: Record<StickyAttributeKey, StickyAssignmentsDocument> = {};
const keys = Object.entries(attributes).map(
([attributeName, attributeValue]) =>
`${attributeName}||${attributeValue}`,
);
if (!this.client) return docs;
await this.client.mget(...keys).then((values) => {
values.forEach((raw) => {
try {
const data = JSON.parse(raw || "{}");
if (data.attributeName && data.attributeValue && data.assignments) {
const key = `${data.attributeName}||${data.attributeValue}`;
docs[key] = data;
}
} catch (e) {
logger.error("unable to parse sticky bucket json");
}
});
});
return docs;
}

public async getAssignments(_attributeName: string, _attributeValue: string) {
// not implemented
return null;
}

public async saveAssignments(doc: StickyAssignmentsDocument) {
const key = `${doc.attributeName}||${doc.attributeValue}`;
if (!this.client) return;
this.writeBuffer[key] = JSON.stringify(doc);
}

public async onEvaluate() {
if (!this.client) return;
if (Object.keys(this.writeBuffer).length === 0) return;
this.client.mset(this.writeBuffer);
this.writeBuffer = {};
}

public getClient() {
return this.client;
}
}
33 changes: 33 additions & 0 deletions packages/apps/proxy/src/services/stickyBucket/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ClusterNode, ClusterOptions } from "ioredis";
import { StickyBucketService } from "@growthbook/growthbook";
import { Context } from "../../types";
import logger from "../logger";
import { RedisStickyBucketService } from "./RedisStickyBucketService";

// enhanced StickyBucketService, optimized for opening connections and listening to remote eval lifecycle events
export let stickyBucketService:
| (StickyBucketService & {
connect: () => Promise<void>;
onEvaluate?: () => Promise<void>;
})
| null = null;

export interface StickyBucketSettings {
connectionUrl?: string;
useCluster?: boolean; // for RedisCache
clusterRootNodesJSON?: ClusterNode[]; // for RedisCache
clusterOptionsJSON?: ClusterOptions; // for RedisCache
}

export const initializeStickyBucketService = async (context: Context) => {
if (
context.stickyBucketSettings &&
context.stickyBucketSettings.engine !== "none"
) {
if (context.stickyBucketSettings.engine === "redis") {
logger.info("using Redis sticky bucketing");
stickyBucketService = new RedisStickyBucketService(context.cacheSettings);
await stickyBucketService.connect();
}
}
};
6 changes: 6 additions & 0 deletions packages/apps/proxy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { HttpLogger } from "pino-http";
import { Connection, Registrar } from "./services/registrar";
import { EventStreamManager } from "./services/eventStreamManager";
import { FeaturesCache, CacheSettings } from "./services/cache";
import { StickyBucketSettings } from "./services/stickyBucket";

export interface GrowthBookProxy {
app: Express;
Expand All @@ -27,6 +28,9 @@ export interface Context {
cacheSettings?: {
cacheEngine: CacheEngine;
} & CacheSettings;
stickyBucketSettings?: {
engine: StickyBucketEngine;
} & StickyBucketSettings;
enableHealthCheck?: boolean;
enableCors?: boolean;
enableAdmin?: boolean;
Expand All @@ -37,10 +41,12 @@ export interface Context {
eventStreamMaxDurationMs?: number;
eventStreamPingIntervalMs?: number;
enableRemoteEval?: boolean;
enableStickyBucketing?: boolean;
proxyAllRequests?: boolean;
environment?: "development" | "production";
verboseDebugging?: boolean;
maxPayloadSize?: string;
}

export type CacheEngine = "memory" | "redis" | "mongo";
export type StickyBucketEngine = "redis" | "none";
4 changes: 2 additions & 2 deletions packages/shared/eval/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@growthbook/proxy-eval",
"description": "Remote evaluation service for proxies, edge workers, or backend APIs",
"version": "1.0.1",
"version": "1.0.2",
"main": "dist/index.js",
"license": "MIT",
"repository": {
Expand All @@ -18,7 +18,7 @@
"dev": "tsc --watch"
},
"dependencies": {
"@growthbook/growthbook": "^0.30.0"
"@growthbook/growthbook": "^0.34.0"
},
"devDependencies": {
"rimraf": "^5.0.5",
Expand Down
20 changes: 18 additions & 2 deletions packages/shared/eval/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { GrowthBook, Context as GBContext } from "@growthbook/growthbook";
import {
GrowthBook,
Context as GBContext,
StickyBucketService,
} from "@growthbook/growthbook";

export function evaluateFeatures({
export async function evaluateFeatures({
payload,
attributes,
forcedVariations,
forcedFeatures,
url,
stickyBucketService = null,
ctx,
}: {
payload: any;
attributes: Record<string, any>;
forcedVariations?: Record<string, number>;
forcedFeatures?: Map<string, any>;
url?: string;
stickyBucketService?:
| (StickyBucketService & { onEvaluate?: () => Promise<void> })
| null;
ctx?: any;
}) {
const evaluatedFeatures: Record<string, any> = {};
Expand All @@ -34,6 +42,9 @@ export function evaluateFeatures({
if (url !== undefined) {
context.url = url;
}
if (stickyBucketService) {
context.stickyBucketService = stickyBucketService;
}

if (features || experiments) {
const gb = new GrowthBook(context);
Expand All @@ -43,6 +54,9 @@ export function evaluateFeatures({
if (ctx?.verboseDebugging) {
gb.debug = true;
}
if (stickyBucketService) {
await gb.refreshStickyBuckets();
}

const gbFeatures = gb.getFeatures();
for (const key in gbFeatures) {
Expand Down Expand Up @@ -85,6 +99,8 @@ export function evaluateFeatures({
}
}

stickyBucketService?.onEvaluate?.();

return {
...payload,
features: evaluatedFeatures,
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b"
integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==

"@growthbook/growthbook@^0.30.0":
version "0.30.0"
resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-0.30.0.tgz#52532c2d5dfa7c017815027678bb241421c308b1"
integrity sha512-ennMHKZIhAZokHHcnA9/tOTLFdBB4DQR0WHT8Lf1pD80jWVv8JBCAzsV1jzSjYUQhb8R8pLR2Kho0IfgjzIZGQ==
"@growthbook/growthbook@^0.34.0":
version "0.34.0"
resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-0.34.0.tgz#df5275fafbbe055c7cfeea2953a4218a9ec9027f"
integrity sha512-3K5JL8QftX7y77EpieYg9anXVYb6+ZafTsrNCWp0HOdmtf4lxHOzCUYwuYDaS3bvmaeIUWJ5hYcOTc6WhNbfJw==
dependencies:
dom-mutator "^0.6.0"

Expand Down