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
71 changes: 64 additions & 7 deletions openfeature-provider/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,72 @@ The provider periodically:

---

## Sticky Assignments
## Materialization Stores

The provider supports **Sticky Assignments** for consistent variant assignments across flag evaluations.
Materialization stores provide persistent storage for sticky variant assignments and custom targeting segments. This enables two key use cases:

**📖 See the [Integration Guide: Sticky Assignments](../INTEGRATION_GUIDE.md#sticky-assignments)** for:
- How sticky assignments work
- Server-managed storage (zero configuration)
- Latency considerations
- Custom storage options (currently Java-only, coming soon to JavaScript)
1. **Sticky Assignments**: Maintain consistent variant assignments across evaluations even when targeting attributes change. This enables pausing intake (stopping new users from entering an experiment) while keeping existing users in their assigned variants.

2. **Custom Targeting via Materialized Segments**: Precomputed sets of identifiers from datasets that should be targeted. Instead of evaluating complex targeting rules at runtime, materializations allow efficient lookup of whether a unit (user, session, etc.) is included in a target segment.

### Default Behavior

⚠️ Warning: If your flags rely on sticky assignments or materialized segments, the default SDK behaviour will prevent those rules from being applied and your evaluations will fall back to default values. For production workloads that need sticky behavior or segment lookups, implement and configure a real `MaterializationStore` (e.g., Redis, DynamoDB, or a key-value store) to avoid unexpected fallbacks and ensure consistent variant assignment.

### Remote Materialization Store

For quick setup without managing your own storage infrastructure, enable the built-in remote materialization store:

```ts
const provider = createConfidenceServerProvider({
flagClientSecret: process.env.CONFIDENCE_FLAG_CLIENT_SECRET!,
materializationStore: 'CONFIDENCE_REMOTE_STORE',
});
```

**When to use**:
- You need sticky assignments or materialized segments but don't want to manage storage infrastructure
- Quick prototyping or getting started
- Lower-volume applications where network latency is acceptable

**Trade-offs**:
- Additional network calls during flag resolution (adds latency)
- Lower performance compared to local storage implementations (Redis, DynamoDB, etc.)

### Custom Implementations

For improved latency and reduced network calls, implement the `MaterializationStore` interface to store materialization data in your infrastructure:

```ts
import { MaterializationStore } from '@spotify-confidence/openfeature-server-provider-local';

class MyRedisStore implements MaterializationStore {
async readMaterializations(readOps: MaterializationStore.ReadOp[]): Promise<MaterializationStore.ReadResult[]> {
// Load materialization data from Redis
}

async writeMaterializations(writeOps: MaterializationStore.WriteOp[]): Promise<void> {
// Store materialization data to Redis
}
}

const provider = createConfidenceServerProvider({
flagClientSecret: process.env.CONFIDENCE_FLAG_CLIENT_SECRET!,
materializationStore: new MyRedisStore(),
});
```

For read-only stores (e.g., pre-populated materialized segments without sticky assignment writes), omit the `writeMaterializations` method.

### When to Use Materialization Stores

Consider implementing a materialization store if:
- You need to support sticky variant assignments for experiments
- You use materialized segments for custom targeting
- You want to minimize network latency during flag resolution
- You have high-volume flag evaluations

If you don't use sticky assignments or materialized segments, the default behavior is sufficient.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const module = new WebAssembly.Module(moduleBytes);
const resolver = new WasmResolver(module);
const confidenceProvider = new ConfidenceServerProviderLocal(resolver, {
flagClientSecret: 'ti5Sipq5EluCYRG7I5cdbpWC3xq7JTWv',
materializationStore: 'CONFIDENCE_REMOTE_STORE',
});

describe('ConfidenceServerProvider E2E tests', () => {
Expand Down
133 changes: 80 additions & 53 deletions openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { abortableSleep, TimeUnit, timeoutSignal } from './util';
import { advanceTimersUntil, NetworkMock } from './test-helpers';
import { sha256Hex } from './hash';
import { ResolveReason } from './proto/confidence/flags/resolver/v1/types';

vi.mock(import('./hash'), async () => {
const { sha256Hex } = await import('./test-helpers');
Expand Down Expand Up @@ -36,6 +37,7 @@ beforeEach(() => {
provider = new ConfidenceServerProviderLocal(mockedWasmResolver, {
flagClientSecret: 'flagClientSecret',
fetch: net.fetch,
materializationStore: 'CONFIDENCE_REMOTE_STORE',
});
});

Expand Down Expand Up @@ -248,7 +250,7 @@ describe('network error modes', () => {
});
});

describe('remote resolver fallback for sticky assignments', () => {
describe('remote materialization for sticky assignments', () => {
const RESOLVE_REASON_MATCH = 1;

it('resolves locally when WASM has all materialization data', async () => {
Expand Down Expand Up @@ -280,50 +282,44 @@ describe('remote resolver fallback for sticky assignments', () => {
expect(result.value).toBe(true);
expect(result.variant).toBe('variant-a');

// Should use failFastOnSticky: true (fallback strategy)
expect(mockedWasmResolver.resolveWithSticky).toHaveBeenCalledWith({
resolveRequest: expect.objectContaining({
flags: ['flags/test-flag'],
clientSecret: 'flagClientSecret',
}),
materializations: [],
failFastOnSticky: true,
failFastOnSticky: false,
notProcessSticky: false,
});

// No remote call needed
expect(net.resolver.flagsResolve.calls).toBe(0);
expect(net.resolver.readMaterializations.calls).toBe(0);
});

it('falls back to remote resolver when WASM reports missing materializations', async () => {
it('reads materializations from remote when WASM reports missing materializations', async () => {
await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined());

// WASM resolver reports missing materialization
mockedWasmResolver.resolveWithSticky.mockReturnValue({
mockedWasmResolver.resolveWithSticky.mockReturnValueOnce({
readOpsRequest: { ops: [{ variantReadOp: { unit: 'user-456', rule: 'rule-1', materialization: 'mat-v1' } }] },
});

// Configure remote resolver response
net.resolver.flagsResolve.handler = () => {
return new Response(
JSON.stringify({
mockedWasmResolver.resolveWithSticky.mockReturnValueOnce({
success: {
materializationUpdates: [],
response: {
resolvedFlags: [
{
flag: 'flags/my-flag',
variant: 'flags/my-flag/variants/control',
value: { color: 'blue', size: 10 },
reason: 'RESOLVE_REASON_MATCH',
reason: ResolveReason.RESOLVE_REASON_MATCH,
},
],
resolveToken: '',
resolveToken: new Uint8Array(),
resolveId: 'remote-resolve-456',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
};
},
});

const result = await provider.resolveObjectEvaluation(
'my-flag',
Expand All @@ -333,40 +329,43 @@ describe('remote resolver fallback for sticky assignments', () => {
country: 'SE',
},
);

expect(result.value).toEqual({ color: 'blue', size: 10 });
expect(result.reason).toEqual('MATCH');
expect(result.variant).toBe('flags/my-flag/variants/control');

// Remote resolver should have been called
expect(net.resolver.flagsResolve.calls).toBe(1);

// Verify auth header was added
const lastRequest = net.resolver.flagsResolve.requests[0];
expect(lastRequest.method).toBe('POST');
// Remote store should have been called
expect(net.resolver.readMaterializations.calls).toBe(1);
});

it('retries remote resolve on transient errors', async () => {
it('retries remote read materialization on transient errors', async () => {
await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined());

mockedWasmResolver.resolveWithSticky.mockReturnValue({
mockedWasmResolver.resolveWithSticky.mockReturnValueOnce({
readOpsRequest: {
ops: [{ variantReadOp: { unit: 'user-1', rule: 'rule-1', materialization: 'mat-1' } }],
},
});
mockedWasmResolver.resolveWithSticky.mockReturnValueOnce({
success: {
materializationUpdates: [],
response: {
resolvedFlags: [
{
flag: 'flags/test-flag',
variant: 'flags/my-flag/variants/control',
value: { ok: true },
reason: ResolveReason.RESOLVE_REASON_MATCH,
},
],
resolveToken: new Uint8Array(),
resolveId: 'remote-resolve-456',
},
},
});

// First two calls fail, third succeeds
net.resolver.flagsResolve.status = 503;
net.resolver.readMaterializations.status = 503;
setTimeout(() => {
net.resolver.flagsResolve.status = 200;
net.resolver.flagsResolve.handler = () =>
new Response(
JSON.stringify({
resolvedFlags: [{ flag: 'test-flag', variant: 'v1', value: { ok: true }, reason: 'RESOLVE_REASON_MATCH' }],
resolveToken: '',
resolveId: 'resolved',
}),
{ status: 200 },
);
net.resolver.readMaterializations.status = 200;
}, 300);

const result = await advanceTimersUntil(
Expand All @@ -375,8 +374,39 @@ describe('remote resolver fallback for sticky assignments', () => {

expect(result.value).toBe(true);
// Should have retried multiple times
expect(net.resolver.flagsResolve.calls).toBeGreaterThan(1);
expect(net.resolver.flagsResolve.calls).toBeLessThanOrEqual(3);
expect(net.resolver.readMaterializations.calls).toBeGreaterThan(1);
expect(net.resolver.readMaterializations.calls).toBeLessThanOrEqual(3);
});

it('writes materializations to remote when WASM reports materialization updates', async () => {
await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined());

mockedWasmResolver.resolveWithSticky.mockReturnValueOnce({
success: {
materializationUpdates: [{ unit: 'u1', materialization: 'm1', rule: 'r1', variant: 'v1' }],
response: {
resolvedFlags: [
{
flag: 'flags/test-flag',
variant: 'flags/my-flag/variants/control',
value: { ok: true },
reason: ResolveReason.RESOLVE_REASON_MATCH,
},
],
resolveToken: new Uint8Array(),
resolveId: 'remote-resolve-456',
},
},
});

await advanceTimersUntil(
expect(provider.resolveBooleanEvaluation('test-flag.ok', false, { targetingKey: 'user-1' })).resolves.toEqual(
expect.objectContaining({ value: true }),
),
);

// SDK doesn't wait for writes so need we need to wait here.
await advanceTimersUntil(() => net.resolver.writeMaterializations.calls === 1);
});
});

Expand Down Expand Up @@ -410,18 +440,15 @@ describe('SDK telemetry', () => {
});

// Verify SDK information is included in the resolve request
expect(mockedWasmResolver.resolveWithSticky).toHaveBeenCalledWith({
resolveRequest: expect.objectContaining({
flags: ['flags/test-flag'],
clientSecret: 'flagClientSecret',
sdk: expect.objectContaining({
id: 22, // SDK_ID_JS_LOCAL_SERVER_PROVIDER
version: expect.stringMatching(/^\d+\.\d+\.\d+$/), // Semantic version format
expect(mockedWasmResolver.resolveWithSticky).toHaveBeenCalledWith(
expect.objectContaining({
resolveRequest: expect.objectContaining({
sdk: expect.objectContaining({
id: 22, // SDK_ID_JS_LOCAL_SERVER_PROVIDER
version: expect.stringMatching(/^\d+\.\d+\.\d+$/), // Semantic version format
}),
}),
}),
materializations: [],
failFastOnSticky: true,
notProcessSticky: false,
});
);
});
});
Loading
Loading