Skip to content

Commit 96aea72

Browse files
feat: pluggable materialization (#211)
1 parent 48246ec commit 96aea72

File tree

10 files changed

+542
-159
lines changed

10 files changed

+542
-159
lines changed

openfeature-provider/js/README.md

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,15 +138,72 @@ The provider periodically:
138138

139139
---
140140

141-
## Sticky Assignments
141+
## Materialization Stores
142142

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

145-
**📖 See the [Integration Guide: Sticky Assignments](../INTEGRATION_GUIDE.md#sticky-assignments)** for:
146-
- How sticky assignments work
147-
- Server-managed storage (zero configuration)
148-
- Latency considerations
149-
- Custom storage options (currently Java-only, coming soon to JavaScript)
145+
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.
146+
147+
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.
148+
149+
### Default Behavior
150+
151+
⚠️ 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.
152+
153+
### Remote Materialization Store
154+
155+
For quick setup without managing your own storage infrastructure, enable the built-in remote materialization store:
156+
157+
```ts
158+
const provider = createConfidenceServerProvider({
159+
flagClientSecret: process.env.CONFIDENCE_FLAG_CLIENT_SECRET!,
160+
materializationStore: 'CONFIDENCE_REMOTE_STORE',
161+
});
162+
```
163+
164+
**When to use**:
165+
- You need sticky assignments or materialized segments but don't want to manage storage infrastructure
166+
- Quick prototyping or getting started
167+
- Lower-volume applications where network latency is acceptable
168+
169+
**Trade-offs**:
170+
- Additional network calls during flag resolution (adds latency)
171+
- Lower performance compared to local storage implementations (Redis, DynamoDB, etc.)
172+
173+
### Custom Implementations
174+
175+
For improved latency and reduced network calls, implement the `MaterializationStore` interface to store materialization data in your infrastructure:
176+
177+
```ts
178+
import { MaterializationStore } from '@spotify-confidence/openfeature-server-provider-local';
179+
180+
class MyRedisStore implements MaterializationStore {
181+
async readMaterializations(readOps: MaterializationStore.ReadOp[]): Promise<MaterializationStore.ReadResult[]> {
182+
// Load materialization data from Redis
183+
}
184+
185+
async writeMaterializations(writeOps: MaterializationStore.WriteOp[]): Promise<void> {
186+
// Store materialization data to Redis
187+
}
188+
}
189+
190+
const provider = createConfidenceServerProvider({
191+
flagClientSecret: process.env.CONFIDENCE_FLAG_CLIENT_SECRET!,
192+
materializationStore: new MyRedisStore(),
193+
});
194+
```
195+
196+
For read-only stores (e.g., pre-populated materialized segments without sticky assignment writes), omit the `writeMaterializations` method.
197+
198+
### When to Use Materialization Stores
199+
200+
Consider implementing a materialization store if:
201+
- You need to support sticky variant assignments for experiments
202+
- You use materialized segments for custom targeting
203+
- You want to minimize network latency during flag resolution
204+
- You have high-volume flag evaluations
205+
206+
If you don't use sticky assignments or materialized segments, the default behavior is sufficient.
150207

151208
---
152209

openfeature-provider/js/src/ConfidenceServerProviderLocal.e2e.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const module = new WebAssembly.Module(moduleBytes);
99
const resolver = new WasmResolver(module);
1010
const confidenceProvider = new ConfidenceServerProviderLocal(resolver, {
1111
flagClientSecret: 'ti5Sipq5EluCYRG7I5cdbpWC3xq7JTWv',
12+
materializationStore: 'CONFIDENCE_REMOTE_STORE',
1213
});
1314

1415
describe('ConfidenceServerProvider E2E tests', () => {

openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts

Lines changed: 80 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { abortableSleep, TimeUnit, timeoutSignal } from './util';
99
import { advanceTimersUntil, NetworkMock } from './test-helpers';
1010
import { sha256Hex } from './hash';
11+
import { ResolveReason } from './proto/confidence/flags/resolver/v1/types';
1112

1213
vi.mock(import('./hash'), async () => {
1314
const { sha256Hex } = await import('./test-helpers');
@@ -36,6 +37,7 @@ beforeEach(() => {
3637
provider = new ConfidenceServerProviderLocal(mockedWasmResolver, {
3738
flagClientSecret: 'flagClientSecret',
3839
fetch: net.fetch,
40+
materializationStore: 'CONFIDENCE_REMOTE_STORE',
3941
});
4042
});
4143

@@ -248,7 +250,7 @@ describe('network error modes', () => {
248250
});
249251
});
250252

251-
describe('remote resolver fallback for sticky assignments', () => {
253+
describe('remote materialization for sticky assignments', () => {
252254
const RESOLVE_REASON_MATCH = 1;
253255

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

283-
// Should use failFastOnSticky: true (fallback strategy)
284285
expect(mockedWasmResolver.resolveWithSticky).toHaveBeenCalledWith({
285286
resolveRequest: expect.objectContaining({
286287
flags: ['flags/test-flag'],
287288
clientSecret: 'flagClientSecret',
288289
}),
289290
materializations: [],
290-
failFastOnSticky: true,
291+
failFastOnSticky: false,
291292
notProcessSticky: false,
292293
});
293294

294295
// No remote call needed
295-
expect(net.resolver.flagsResolve.calls).toBe(0);
296+
expect(net.resolver.readMaterializations.calls).toBe(0);
296297
});
297298

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

301302
// WASM resolver reports missing materialization
302-
mockedWasmResolver.resolveWithSticky.mockReturnValue({
303+
mockedWasmResolver.resolveWithSticky.mockReturnValueOnce({
303304
readOpsRequest: { ops: [{ variantReadOp: { unit: 'user-456', rule: 'rule-1', materialization: 'mat-v1' } }] },
304305
});
305-
306-
// Configure remote resolver response
307-
net.resolver.flagsResolve.handler = () => {
308-
return new Response(
309-
JSON.stringify({
306+
mockedWasmResolver.resolveWithSticky.mockReturnValueOnce({
307+
success: {
308+
materializationUpdates: [],
309+
response: {
310310
resolvedFlags: [
311311
{
312312
flag: 'flags/my-flag',
313313
variant: 'flags/my-flag/variants/control',
314314
value: { color: 'blue', size: 10 },
315-
reason: 'RESOLVE_REASON_MATCH',
315+
reason: ResolveReason.RESOLVE_REASON_MATCH,
316316
},
317317
],
318-
resolveToken: '',
318+
resolveToken: new Uint8Array(),
319319
resolveId: 'remote-resolve-456',
320-
}),
321-
{
322-
status: 200,
323-
headers: { 'Content-Type': 'application/json' },
324320
},
325-
);
326-
};
321+
},
322+
});
327323

328324
const result = await provider.resolveObjectEvaluation(
329325
'my-flag',
@@ -333,40 +329,43 @@ describe('remote resolver fallback for sticky assignments', () => {
333329
country: 'SE',
334330
},
335331
);
336-
337-
expect(result.value).toEqual({ color: 'blue', size: 10 });
332+
expect(result.reason).toEqual('MATCH');
338333
expect(result.variant).toBe('flags/my-flag/variants/control');
339334

340-
// Remote resolver should have been called
341-
expect(net.resolver.flagsResolve.calls).toBe(1);
342-
343-
// Verify auth header was added
344-
const lastRequest = net.resolver.flagsResolve.requests[0];
345-
expect(lastRequest.method).toBe('POST');
335+
// Remote store should have been called
336+
expect(net.resolver.readMaterializations.calls).toBe(1);
346337
});
347338

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

351-
mockedWasmResolver.resolveWithSticky.mockReturnValue({
342+
mockedWasmResolver.resolveWithSticky.mockReturnValueOnce({
352343
readOpsRequest: {
353344
ops: [{ variantReadOp: { unit: 'user-1', rule: 'rule-1', materialization: 'mat-1' } }],
354345
},
355346
});
347+
mockedWasmResolver.resolveWithSticky.mockReturnValueOnce({
348+
success: {
349+
materializationUpdates: [],
350+
response: {
351+
resolvedFlags: [
352+
{
353+
flag: 'flags/test-flag',
354+
variant: 'flags/my-flag/variants/control',
355+
value: { ok: true },
356+
reason: ResolveReason.RESOLVE_REASON_MATCH,
357+
},
358+
],
359+
resolveToken: new Uint8Array(),
360+
resolveId: 'remote-resolve-456',
361+
},
362+
},
363+
});
356364

357365
// First two calls fail, third succeeds
358-
net.resolver.flagsResolve.status = 503;
366+
net.resolver.readMaterializations.status = 503;
359367
setTimeout(() => {
360-
net.resolver.flagsResolve.status = 200;
361-
net.resolver.flagsResolve.handler = () =>
362-
new Response(
363-
JSON.stringify({
364-
resolvedFlags: [{ flag: 'test-flag', variant: 'v1', value: { ok: true }, reason: 'RESOLVE_REASON_MATCH' }],
365-
resolveToken: '',
366-
resolveId: 'resolved',
367-
}),
368-
{ status: 200 },
369-
);
368+
net.resolver.readMaterializations.status = 200;
370369
}, 300);
371370

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

376375
expect(result.value).toBe(true);
377376
// Should have retried multiple times
378-
expect(net.resolver.flagsResolve.calls).toBeGreaterThan(1);
379-
expect(net.resolver.flagsResolve.calls).toBeLessThanOrEqual(3);
377+
expect(net.resolver.readMaterializations.calls).toBeGreaterThan(1);
378+
expect(net.resolver.readMaterializations.calls).toBeLessThanOrEqual(3);
379+
});
380+
381+
it('writes materializations to remote when WASM reports materialization updates', async () => {
382+
await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined());
383+
384+
mockedWasmResolver.resolveWithSticky.mockReturnValueOnce({
385+
success: {
386+
materializationUpdates: [{ unit: 'u1', materialization: 'm1', rule: 'r1', variant: 'v1' }],
387+
response: {
388+
resolvedFlags: [
389+
{
390+
flag: 'flags/test-flag',
391+
variant: 'flags/my-flag/variants/control',
392+
value: { ok: true },
393+
reason: ResolveReason.RESOLVE_REASON_MATCH,
394+
},
395+
],
396+
resolveToken: new Uint8Array(),
397+
resolveId: 'remote-resolve-456',
398+
},
399+
},
400+
});
401+
402+
await advanceTimersUntil(
403+
expect(provider.resolveBooleanEvaluation('test-flag.ok', false, { targetingKey: 'user-1' })).resolves.toEqual(
404+
expect.objectContaining({ value: true }),
405+
),
406+
);
407+
408+
// SDK doesn't wait for writes so need we need to wait here.
409+
await advanceTimersUntil(() => net.resolver.writeMaterializations.calls === 1);
380410
});
381411
});
382412

@@ -410,18 +440,15 @@ describe('SDK telemetry', () => {
410440
});
411441

412442
// Verify SDK information is included in the resolve request
413-
expect(mockedWasmResolver.resolveWithSticky).toHaveBeenCalledWith({
414-
resolveRequest: expect.objectContaining({
415-
flags: ['flags/test-flag'],
416-
clientSecret: 'flagClientSecret',
417-
sdk: expect.objectContaining({
418-
id: 22, // SDK_ID_JS_LOCAL_SERVER_PROVIDER
419-
version: expect.stringMatching(/^\d+\.\d+\.\d+$/), // Semantic version format
443+
expect(mockedWasmResolver.resolveWithSticky).toHaveBeenCalledWith(
444+
expect.objectContaining({
445+
resolveRequest: expect.objectContaining({
446+
sdk: expect.objectContaining({
447+
id: 22, // SDK_ID_JS_LOCAL_SERVER_PROVIDER
448+
version: expect.stringMatching(/^\d+\.\d+\.\d+$/), // Semantic version format
449+
}),
420450
}),
421451
}),
422-
materializations: [],
423-
failFastOnSticky: true,
424-
notProcessSticky: false,
425-
});
452+
);
426453
});
427454
});

0 commit comments

Comments
 (0)