Skip to content

Commit baeff3e

Browse files
nicklaslclaude
andauthored
feat(js): add sticky assignment support through remote resolver fallback (#48)
Co-authored-by: Claude <[email protected]>
1 parent 9478533 commit baeff3e

15 files changed

+425
-56
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,21 @@ jobs:
3434
uses: docker/build-push-action@v6
3535
with:
3636
context: .
37-
target: all
3837
push: false
3938
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}/cache:main
39+
secrets: |
40+
"js_e2e_test_env=${{ secrets.JS_E2E_TEST_ENV }}"
4041
4142
- name: Build and test everything (Push - updates cache)
4243
if: github.event_name == 'push'
4344
uses: docker/build-push-action@v6
4445
with:
4546
context: .
46-
target: all
4747
push: false
4848
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}/cache:main
4949
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}/cache:main,mode=max
50+
secrets: |
51+
"js_e2e_test_env=${{ secrets.JS_E2E_TEST_ENV }}"
5052
5153
- name: Extract artifacts
5254
run: |

Dockerfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,11 +394,22 @@ COPY --from=wasm-rust-guest.artifact /confidence_resolver.wasm ../../../wasm/con
394394
# Test OpenFeature Provider
395395
# ==============================================================================
396396
FROM openfeature-provider-js-base AS openfeature-provider-js.test
397+
397398
# Copy confidence-resolver protos (needed by some tests for proto parsing)
398399
COPY confidence-resolver/protos ../../../confidence-resolver/protos
399400
COPY wasm/resolver_state.pb ../../../wasm/resolver_state.pb
401+
400402
RUN make test
401403

404+
# ==============================================================================
405+
# E2E Test OpenFeature Provider (requires credentials)
406+
# ==============================================================================
407+
FROM openfeature-provider-js.test AS openfeature-provider-js.test_e2e
408+
409+
# Run e2e tests with secrets mounted as .env.test file
410+
RUN --mount=type=secret,id=js_e2e_test_env,target=.env.test \
411+
make test-e2e
412+
402413
# ==============================================================================
403414
# Build OpenFeature Provider
404415
# ==============================================================================
@@ -466,6 +477,7 @@ COPY --from=wasm-rust-guest.artifact /confidence_resolver.wasm /artifacts/wasm/
466477
COPY --from=confidence-resolver.test /workspace/Cargo.toml /markers/test-resolver
467478
COPY --from=wasm-msg.test /workspace/Cargo.toml /markers/test-wasm-msg
468479
COPY --from=openfeature-provider-js.test /app/package.json /markers/test-openfeature-js
480+
COPY --from=openfeature-provider-js.test_e2e /app/package.json /markers/test-openfeature-js-e2e
469481
COPY --from=openfeature-provider-java.test /app/pom.xml /markers/test-openfeature-java
470482

471483
# Force integration test stages to run (host examples)

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ The Confidence Flag Resolver implemented in Rust, plus example hosts and a Cloud
2828
docker build . # Build, test, lint everything
2929
make # Same, using Makefile
3030

31+
# E2E tests require Confidence credentials passed as Docker secret
32+
# Create openfeature-provider/js/.env.test with your credentials, then:
33+
docker build \
34+
--secret id=js_e2e_test_env,src=openfeature-provider/js/.env.test \
35+
.
36+
3137
# With local tools (fast iteration)
3238
make test # Run tests
3339
make lint # Run linting

openfeature-provider/js/Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ build: $(BUILD_STAMP)
4444
test: $(WASM_ARTIFACT) $(INSTALL_STAMP) $(GEN_TS)
4545
yarn test --run --exclude='**/*.e2e.test.ts'
4646

47+
test-e2e: $(WASM_ARTIFACT) $(INSTALL_STAMP) $(GEN_TS)
48+
yarn test --run e2e.test
49+
4750
clean:
4851
rm -rf node_modules dist src/proto $(INSTALL_STAMP) $(BUILD_STAMP)
4952

openfeature-provider/js/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,44 @@ The provider periodically:
7878

7979
---
8080

81+
## Sticky Assignments
82+
83+
Confidence supports "sticky" flag assignments to ensure users receive consistent variant assignments even when their context changes or flag configurations are updated. This SDK falls back to a cloud resolve in these cases.
84+
85+
> **ℹ️ Latency Considerations**
86+
>
87+
> When a sticky assignment is needed, the provider makes a **network call to Confidence's cloud resolvers**. This introduces additional latency (typically 50-200ms depending on your location) compared to local WASM evaluation.
88+
>
89+
> **Coming soon**: We're developing support for custom materialization storage (Redis, database, etc.) that will eliminate this network call and keep evaluation latency consistently low.
90+
91+
### How it works
92+
93+
When a flag is evaluated for a user, Confidence creates a "materialization" - a snapshot of which variant that user was assigned. On subsequent evaluations, the same variant is returned even if:
94+
- The user's context attributes change (e.g., different country, device type)
95+
- The flag's targeting rules are modified
96+
- New assignments are paused
97+
98+
### Implementation
99+
100+
The provider uses a **remote resolver fallback** for sticky assignments:
101+
- First, the local WASM resolver attempts to resolve the flag
102+
- If sticky assignment data is needed, the provider makes a network call to Confidence's cloud resolvers
103+
- Materializations are stored on Confidence servers with a **90-day TTL** (automatically renewed on access)
104+
- No local storage or database setup required
105+
106+
### Benefits
107+
108+
- **Zero configuration**: Works out of the box with no additional setup
109+
- **Managed storage**: Confidence handles all storage, TTL, and consistency
110+
- **Automatic renewal**: TTL is refreshed on each access
111+
- **Global availability**: Materializations are available across all your services
112+
113+
### Coming Soon: Custom Materialization Storage
114+
115+
We're working on support for connecting your own materialization storage repository (Redis, database, file system, etc.) to eliminate network calls for sticky assignments and have full control over storage. This feature is currently in development.
116+
117+
---
118+
81119
## Logging (optional)
82120

83121
Logging uses the `debug` library if present; otherwise, all log calls are no-ops.

openfeature-provider/js/proto/api.proto

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,68 @@ message SetResolverStateRequest {
9090
bytes state = 1;
9191
string account_id = 2;
9292
}
93+
94+
// Request for resolving flags with sticky (materialized) assignments
95+
message ResolveWithStickyRequest {
96+
// The standard resolve request
97+
ResolveFlagsRequest resolve_request = 1;
98+
99+
// Context about the materialization required for the resolve
100+
// Map of targeting key (unit) to their materialization data
101+
map<string, MaterializationMap> materializations_per_unit = 2;
102+
103+
// If a materialization info is missing, return to the caller immediately
104+
bool fail_fast_on_sticky = 3;
105+
}
106+
107+
// Map of materialization IDs to their info for a specific unit
108+
message MaterializationMap {
109+
// Map of materialization name to info
110+
map<string, MaterializationInfo> info_map = 1;
111+
}
112+
113+
// Information about a materialization for a specific unit
114+
message MaterializationInfo {
115+
// Whether the unit is in this materialization/rollout
116+
bool unit_in_info = 1;
117+
118+
// Map of rule name to variant name for this unit
119+
map<string, string> rule_to_variant = 2;
120+
}
121+
122+
// Response from resolving with sticky assignments
123+
message ResolveWithStickyResponse {
124+
oneof resolve_result {
125+
Success success = 1;
126+
MissingMaterializations missing_materializations = 2;
127+
}
128+
129+
// Successful resolution with materialization updates
130+
message Success {
131+
// The resolved flags response
132+
ResolveFlagsResponse response = 1;
133+
134+
// New assignments that should be stored
135+
repeated MaterializationUpdate updates = 2;
136+
}
137+
138+
// Information about missing materializations
139+
message MissingMaterializations {
140+
repeated MissingMaterializationItem items = 1;
141+
}
142+
143+
// A missing materialization item
144+
message MissingMaterializationItem {
145+
string unit = 1;
146+
string rule = 2;
147+
string read_materialization = 3;
148+
}
149+
150+
// A materialization update to be stored
151+
message MaterializationUpdate {
152+
string unit = 1;
153+
string write_materialization = 2;
154+
string rule = 3;
155+
string variant = 4;
156+
}
157+
}

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@ import { readFileSync } from 'node:fs';
55
import { WasmResolver } from './WasmResolver';
66

77
const {
8-
CONFIDENCE_API_CLIENT_ID,
9-
CONFIDENCE_API_CLIENT_SECRET,
10-
} = requireEnv('CONFIDENCE_API_CLIENT_ID', 'CONFIDENCE_API_CLIENT_SECRET');
8+
JS_E2E_CONFIDENCE_API_CLIENT_ID,
9+
JS_E2E_CONFIDENCE_API_CLIENT_SECRET,
10+
} = requireEnv('JS_E2E_CONFIDENCE_API_CLIENT_ID', 'JS_E2E_CONFIDENCE_API_CLIENT_SECRET');
1111

1212
const moduleBytes = readFileSync(__dirname + '/../../../wasm/confidence_resolver.wasm');
1313
const module = new WebAssembly.Module(moduleBytes);
1414
const resolver = await WasmResolver.load(module);
1515
const confidenceProvider = new ConfidenceServerProviderLocal(resolver, {
1616
flagClientSecret: 'RxDVTrXvc6op1XxiQ4OaR31dKbJ39aYV',
17-
apiClientId: CONFIDENCE_API_CLIENT_ID,
18-
apiClientSecret: CONFIDENCE_API_CLIENT_SECRET
17+
apiClientId: JS_E2E_CONFIDENCE_API_CLIENT_ID,
18+
apiClientSecret: JS_E2E_CONFIDENCE_API_CLIENT_SECRET
1919
});
2020

2121
describe('ConfidenceServerProvider E2E tests', () => {
@@ -84,6 +84,18 @@ describe('ConfidenceServerProvider E2E tests', () => {
8484

8585
expect(await client.getNumberDetails('web-sdk-e2e-flag.obj.double', 1)).toEqual(expectedObject);
8686
});
87+
88+
it('should resolve a flag with a sticky resolve', async () => {
89+
const client = OpenFeature.getClient();
90+
const result = await client.getNumberDetails('web-sdk-e2e-flag.double', -1, { targetingKey: 'test-a', sticky: true });
91+
92+
// The flag has a running experiment with a sticky assignment. The intake is paused but we should still get the sticky assignment.
93+
// If this test breaks it could mean that the experiment was removed or that the bigtable materialization was cleaned out.
94+
expect(result.value).toBe(99.99);
95+
expect(result.variant).toBe('flags/web-sdk-e2e-flag/variants/sticky');
96+
expect(result.reason).toBe('MATCH');
97+
98+
});
8799
});
88100

89101
function requireEnv<const N extends string[]>(...names:N): Record<N[number],string> {

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

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ import { LocalResolver } from './LocalResolver';
33
import { ConfidenceServerProviderLocal, DEFAULT_FLUSH_INTERVAL, DEFAULT_STATE_INTERVAL } from './ConfidenceServerProviderLocal';
44
import { abortableSleep, TimeUnit, timeoutSignal } from './util';
55
import { advanceTimersUntil, NetworkMock } from './test-helpers';
6-
import { aD } from 'vitest/dist/chunks/reporters.d.BFLkQcL6.js';
76

87

98
const mockedWasmResolver:MockedObject<LocalResolver> = {
10-
resolveFlags: vi.fn(),
9+
resolveWithSticky: vi.fn(),
1110
setResolverState: vi.fn(),
1211
flushLogs: vi.fn().mockReturnValue(new Uint8Array(100))
1312
}
@@ -430,4 +429,130 @@ describe('network error modes', () => {
430429
);
431430
expect(net.resolver.flagLogs.calls).toBeGreaterThan(1);
432431
});
432+
});
433+
434+
describe('remote resolver fallback for sticky assignments', () => {
435+
const RESOLVE_REASON_MATCH = 1;
436+
437+
it('resolves locally when WASM has all materialization data', async () => {
438+
await advanceTimersUntil(
439+
expect(provider.initialize()).resolves.toBeUndefined()
440+
);
441+
442+
// WASM resolver succeeds with local data
443+
mockedWasmResolver.resolveWithSticky.mockReturnValue({
444+
success: {
445+
response: {
446+
resolvedFlags: [{
447+
flag: 'test-flag',
448+
variant: 'variant-a',
449+
value: { enabled: true },
450+
reason: RESOLVE_REASON_MATCH
451+
}],
452+
resolveToken: new Uint8Array(),
453+
resolveId: 'resolve-123'
454+
},
455+
updates: []
456+
}
457+
});
458+
459+
const result = await provider.resolveBooleanEvaluation('test-flag.enabled', false, {
460+
targetingKey: 'user-123'
461+
});
462+
463+
expect(result.value).toBe(true);
464+
expect(result.variant).toBe('variant-a');
465+
466+
// Should use failFastOnSticky: true (fallback strategy)
467+
expect(mockedWasmResolver.resolveWithSticky).toHaveBeenCalledWith({
468+
resolveRequest: expect.objectContaining({
469+
flags: ['flags/test-flag'],
470+
clientSecret: 'flagClientSecret'
471+
}),
472+
materializationsPerUnit: {},
473+
failFastOnSticky: true
474+
});
475+
476+
// No remote call needed
477+
expect(net.resolver.flagsResolve.calls).toBe(0);
478+
});
479+
480+
it('falls back to remote resolver when WASM reports missing materializations', async () => {
481+
await advanceTimersUntil(
482+
expect(provider.initialize()).resolves.toBeUndefined()
483+
);
484+
485+
// WASM resolver reports missing materialization
486+
mockedWasmResolver.resolveWithSticky.mockReturnValue({
487+
missingMaterializations: {
488+
items: [
489+
{ unit: 'user-456', rule: 'rule-1', readMaterialization: 'mat-v1' }
490+
]
491+
}
492+
});
493+
494+
// Configure remote resolver response
495+
net.resolver.flagsResolve.handler = () => {
496+
return new Response(JSON.stringify({
497+
resolvedFlags: [{
498+
flag: 'flags/my-flag',
499+
variant: 'flags/my-flag/variants/control',
500+
value: { color: 'blue', size: 10 },
501+
reason: 'RESOLVE_REASON_MATCH'
502+
}],
503+
resolveToken: '',
504+
resolveId: 'remote-resolve-456'
505+
}), {
506+
status: 200,
507+
headers: { 'Content-Type': 'application/json' }
508+
});
509+
};
510+
511+
const result = await provider.resolveObjectEvaluation('my-flag', { color: 'red' }, {
512+
targetingKey: 'user-456',
513+
country: 'SE'
514+
});
515+
516+
expect(result.value).toEqual({ color: 'blue', size: 10 });
517+
expect(result.variant).toBe('flags/my-flag/variants/control');
518+
519+
// Remote resolver should have been called
520+
expect(net.resolver.flagsResolve.calls).toBe(1);
521+
522+
// Verify auth header was added
523+
const lastRequest = net.resolver.flagsResolve.requests[0];
524+
expect(lastRequest.method).toBe('POST');
525+
});
526+
527+
it('retries remote resolve on transient errors', async () => {
528+
await advanceTimersUntil(
529+
expect(provider.initialize()).resolves.toBeUndefined()
530+
);
531+
532+
mockedWasmResolver.resolveWithSticky.mockReturnValue({
533+
missingMaterializations: {
534+
items: [{ unit: 'user-1', rule: 'rule-1', readMaterialization: 'mat-1' }]
535+
}
536+
});
537+
538+
// First two calls fail, third succeeds
539+
net.resolver.flagsResolve.status = 503;
540+
setTimeout(() => {
541+
net.resolver.flagsResolve.status = 200;
542+
net.resolver.flagsResolve.handler = () => new Response(JSON.stringify({
543+
resolvedFlags: [{ flag: 'test-flag', variant: 'v1', value: { ok: true }, reason: 'RESOLVE_REASON_MATCH' }],
544+
resolveToken: '',
545+
resolveId: 'resolved'
546+
}), { status: 200 });
547+
}, 300);
548+
549+
const result = await advanceTimersUntil(
550+
provider.resolveBooleanEvaluation('test-flag.ok', false, { targetingKey: 'user-1' })
551+
);
552+
553+
expect(result.value).toBe(true);
554+
// Should have retried multiple times
555+
expect(net.resolver.flagsResolve.calls).toBeGreaterThan(1);
556+
expect(net.resolver.flagsResolve.calls).toBeLessThanOrEqual(3);
557+
});
433558
});

0 commit comments

Comments
 (0)