Skip to content
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
7 changes: 7 additions & 0 deletions .changeset/olive-ducks-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"featurehub-javascript-core-sdk": patch
"featurehub-javascript-client-sdk": patch
"featurehub-javascript-node-sdk": patch
---

Create the usage adapter once per repository rather than on every `repository()` call. Because `newContext()` calls `repository()`, every context previously registered a usage stream that was never removed, so feature evaluation fanned out over an ever-growing list of listeners and got progressively slower for the life of the process.
82 changes: 82 additions & 0 deletions packages/core/src/__tests__/usage_adapter_reuse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";

import {
ClientFeatureRepository,
EdgeFeatureHubConfig,
FeatureValueType,
SSEResultState,
} from "../index";

// `repository()` used to build a new UsageAdapter on every call, and each adapter
// registers a usage stream on the repository that is never removed. Because newContext()
// calls repository(), a service that builds a context per request accumulated one usage
// stream per request, and every feature evaluation then fanned out over all of them --
// so evaluation cost grew with the number of contexts the process had ever created.
//
// The stream map is private, so read it through a narrow cast rather than widening the
// public API just for this assertion.
const usageStreamCount = (repository: ClientFeatureRepository): number =>
(repository as unknown as { _usageStreams: Map<number, unknown> })._usageStreams.size;

const repositoryWithFeature = () => {
const repository = new ClientFeatureRepository();
repository.notify(
SSEResultState.Features,
[
{
id: "1",
key: "FEATURE",
l: false,
version: 1,
type: FeatureValueType.Boolean,
value: true,
},
],
"test",
);
return repository;
};

describe("the usage adapter is created once per repository", () => {
it("does not register a new usage stream on every repository() call", () => {
const config = new EdgeFeatureHubConfig();
const repository = repositoryWithFeature();
config.repository(repository);

const initial = usageStreamCount(repository);
for (let i = 0; i < 100; i++) {
config.repository();
}

expect(usageStreamCount(repository)).toBe(initial);
});

it("does not register a new usage stream for every context", () => {
const config = new EdgeFeatureHubConfig();
config.isClientEvaluated = true;
const repository = repositoryWithFeature();
config.repository(repository);

const initial = usageStreamCount(repository);
for (let i = 0; i < 100; i++) {
config.newContext().feature("FEATURE").value;
}

expect(usageStreamCount(repository)).toBe(initial);
});

it("rebuilds the adapter when the repository is replaced, releasing the old stream", () => {
const config = new EdgeFeatureHubConfig();
const first = repositoryWithFeature();
const second = repositoryWithFeature();

config.repository(first);
const attached = usageStreamCount(first);
expect(attached).toBeGreaterThan(0);

config.repository(second);

expect(usageStreamCount(second)).toBe(attached);
expect(usageStreamCount(first)).toBe(attached - 1);
});
});
14 changes: 12 additions & 2 deletions packages/core/src/edge_featurehub_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export class EdgeFeatureHubConfig implements FeatureHubConfig {
private _clientContext: ServerEvalFeatureContext | undefined;
private _initialized = false;
private _usageAdapter: UsageAdapter | undefined;
private _usageAdapterRepository: InternalFeatureRepository | undefined;
private _timeout: number | undefined = undefined;
private _edgeType: EdgeType = EdgeType.STREAMING;
private readonly _noopMode: boolean;
Expand Down Expand Up @@ -388,8 +389,17 @@ export class EdgeFeatureHubConfig implements FeatureHubConfig {
this._repository = new ClientFeatureRepository();
}

this._usageAdapter = new UsageAdapter(this._repository);
this._usageAdapter.registerPlugin(new PassiveRestUsagePlugin(this));
// The usage adapter subscribes to the repository and nothing removes that
// subscription, so it has to be created once per repository rather than on every
// call. newContext() calls this method, so creating one here unconditionally leaked
// a usage stream per context, and every feature evaluation then fanned out over all
// of them.
if (this._usageAdapterRepository !== this._repository) {
this._usageAdapter?.close();
this._usageAdapter = new UsageAdapter(this._repository);
this._usageAdapter.registerPlugin(new PassiveRestUsagePlugin(this));
this._usageAdapterRepository = this._repository;
}

return this._repository;
}
Expand Down
Loading