From c715162850111d810a13bf5fa3a61f038d055cf3 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 24 Sep 2026 11:03:11 -0400 Subject: [PATCH 1/3] fix: Support the v2 harness persistence config shape and daemon mode in the contract test service --- .../server-node/contract-tests/package.json | 3 +- .../contract-tests/src/sdkClientEntity.ts | 124 +++++++++++++----- .../testharness-suppressions-fdv2.txt | 21 +++ 3 files changed, 117 insertions(+), 31 deletions(-) diff --git a/packages/sdk/server-node/contract-tests/package.json b/packages/sdk/server-node/contract-tests/package.json index 421efb8998..017b153050 100644 --- a/packages/sdk/server-node/contract-tests/package.json +++ b/packages/sdk/server-node/contract-tests/package.json @@ -17,7 +17,8 @@ "@launchdarkly/node-server-sdk-dynamodb": "workspace:^", "@launchdarkly/node-server-sdk-redis": "workspace:^", "body-parser": "^2.3.0", - "express": "^5.2.1" + "express": "^5.2.1", + "ioredis": "^5.3.2" }, "devDependencies": { "@types/body-parser": "^1.19.2", diff --git a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts index d92d5c7cc7..5dc8a09d47 100644 --- a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts +++ b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts @@ -1,3 +1,5 @@ +import { Redis } from 'ioredis'; + import { CommandParams, CreateInstanceParams, @@ -56,6 +58,12 @@ interface SDKConfigDataSystemWithStore { storeMode?: 0 | 1; } +// Harness major version 2 sends the persistence config at the top level of +// the SDK config rather than inside dataSystem. +interface SDKConfigWithTopLevelStore { + persistentDataStore?: SDKConfigPersistentDataStoreParams; +} + // A cache TTL, in seconds, used to approximate the harness's "infinite" // cache mode. The SDK's persistent store wrapper has no dedicated infinite // cache mode, so this is a TTL far longer than any contract test run. @@ -64,7 +72,16 @@ const infiniteCacheTTLSeconds = 24 * 60 * 60; // The harness creates this DynamoDB table itself before each test. const dynamoDBTableName = 'sdk-contract-tests'; -function makePersistentStore(params: SDKConfigPersistentDataStoreParams) { +interface PersistentStoreHandle { + store: ReturnType | ReturnType; + // Closes any connection the entity created for the store. The store does + // not close a client that was given to it from the outside. + close: () => void; +} + +async function makePersistentStore( + params: SDKConfigPersistentDataStoreParams, +): Promise { let cacheTTL: number; switch (params.cache.mode) { case 'off': @@ -82,46 +99,73 @@ function makePersistentStore(params: SDKConfigPersistentDataStoreParams) { switch (params.store.type) { case 'redis': { const dsn = new URL(params.store.dsn); - return RedisFeatureStore({ + const client = new Redis({ + host: dsn.hostname, + port: Number(dsn.port), // The harness simulates outages with a TCP proxy, and buffered commands would // otherwise hide write failures from the SDK. - redisOpts: { - host: dsn.hostname, - port: Number(dsn.port), - enableOfflineQueue: false, - maxRetriesPerRequest: 1, - }, - prefix: params.store.prefix, - cacheTTL, + enableOfflineQueue: false, + maxRetriesPerRequest: 1, }); + // With the offline queue disabled, a command sent before the connection + // is ready fails immediately. Wait for the connection so that the SDK's + // first writes do not race it. + await new Promise((resolve) => { + client.once('ready', () => resolve()); + }); + return { + store: RedisFeatureStore({ + client, + prefix: params.store.prefix, + cacheTTL, + }), + close: () => { + // quit rejects when the connection is down and leaves the client + // retrying forever, so always follow it with a hard disconnect. + client + .quit() + .catch(() => {}) + .finally(() => client.disconnect()); + }, + }; } case 'dynamodb': - return DynamoDBFeatureStore(dynamoDBTableName, { - // The harness sends the local DynamoDB endpoint as the DSN. The region - // and static credentials match what the harness's own client uses. - clientOptions: { - endpoint: params.store.dsn, - region: 'us-east-1', - credentials: { - accessKeyId: 'dummy', - secretAccessKey: 'dummy', - sessionToken: 'dummy', + return { + store: DynamoDBFeatureStore(dynamoDBTableName, { + // The harness sends the local DynamoDB endpoint as the DSN. The region + // and static credentials match what the harness's own client uses. + clientOptions: { + endpoint: params.store.dsn, + region: 'us-east-1', + credentials: { + accessKeyId: 'dummy', + secretAccessKey: 'dummy', + sessionToken: 'dummy', + }, }, - }, - prefix: params.store.prefix, - cacheTTL, - }); + prefix: params.store.prefix, + cacheTTL, + }), + close: () => {}, + }; default: throw new Error(`Unsupported persistent data store type: ${params.store.type}`); } } -export function makeSdkConfig(options: ServerSDKConfigParams, tag: string): LDOptions { +export async function makeSdkConfig( + options: ServerSDKConfigParams, + tag: string, +): Promise<{ config: LDOptions; closeStore: () => void }> { const cf: LDOptions = { logger: sdkLogger(tag), diagnosticOptOut: true, }; + // A config has at most one persistent store. This closes the connection the + // entity created for it, if any. + let closeStore: () => void = () => {}; + const maybeTime = (seconds?: number) => seconds === undefined || seconds === null ? undefined : seconds / 1000; @@ -245,7 +289,9 @@ export function makeSdkConfig(options: ServerSDKConfigParams, tag: string): LDOp const persistentDataStore = (options.dataSystem as SDKConfigDataSystemWithStore).store ?.persistentDataStore; if (persistentDataStore) { - cf.dataSystem.persistentStore = makePersistentStore(persistentDataStore); + const handle = await makePersistentStore(persistentDataStore); + cf.dataSystem.persistentStore = handle.store; + closeStore = handle.close; } // FDv1Fallback configures the SDK's FDv1 Fallback Synchronizer -- engaged only in @@ -257,9 +303,24 @@ export function makeSdkConfig(options: ServerSDKConfigParams, tag: string): LDOp pollInterval: maybeTime(options.dataSystem.fdv1Fallback.pollIntervalMs), }; } + } else { + // The v2 harness sends the persistence config at the top level of the SDK + // config. Map it to the FDv1 featureStore option. + const persistentDataStore = (options as SDKConfigWithTopLevelStore).persistentDataStore; + if (persistentDataStore) { + const handle = await makePersistentStore(persistentDataStore); + cf.featureStore = handle.store; + closeStore = handle.close; + // A store with no streaming and no polling source is the harness's + // daemon-mode configuration: the SDK reads from the store and starts + // no data source of its own. + if (!options.streaming && !options.polling) { + cf.useLdd = true; + } + } } - return cf; + return { config: cf, closeStore }; } function getExecution(order: string) { @@ -318,10 +379,11 @@ export async function newSdkClientEntity(options: CreateInstanceParams): Promise options.configuration.startWaitTimeMs !== undefined ? options.configuration.startWaitTimeMs : 5000; - const client: LDClient = ld.init( - options.configuration.credential || 'unknown-sdk-key', - makeSdkConfig(options.configuration as ServerSDKConfigParams, options.tag), + const { config, closeStore } = await makeSdkConfig( + options.configuration as ServerSDKConfigParams, + options.tag, ); + const client: LDClient = ld.init(options.configuration.credential || 'unknown-sdk-key', config); try { await client.waitForInitialization({ timeout }); } catch (_) { @@ -329,6 +391,7 @@ export async function newSdkClientEntity(options: CreateInstanceParams): Promise } if (!client.initialized() && !options.configuration.initCanFail) { client.close(); + closeStore(); throw new Error('client initialization failed'); } @@ -339,6 +402,7 @@ export async function newSdkClientEntity(options: CreateInstanceParams): Promise }); listeners.clear(); client.close(); + closeStore(); log.info('Test ended'); }; diff --git a/packages/sdk/server-node/contract-tests/testharness-suppressions-fdv2.txt b/packages/sdk/server-node/contract-tests/testharness-suppressions-fdv2.txt index 22c1f2830c..22a34b9a08 100644 --- a/packages/sdk/server-node/contract-tests/testharness-suppressions-fdv2.txt +++ b/packages/sdk/server-node/contract-tests/testharness-suppressions-fdv2.txt @@ -11,3 +11,24 @@ streaming/fdv2/reconnection state management/replaces previously known state streaming/fdv2/reconnection state management/updates previously known state streaming/fdv2/ignores model version streaming/fdv2/can discard partial events on errors + +# These tests configure a persistent store with no data sources (the daemon-mode +# pattern; the prefix tests also create clients with zero sources). The FDv2 data +# system cannot yet initialize from a store read alone, so these clients never +# report ready. Remove these entries when that gap is closed. +persistent data store/redis/uses default prefix +persistent data store/redis/uses custom prefix +persistent data store/redis/store mode read-write - no data source/no cache - shows changes immediately +persistent data store/redis/store mode read-write - no data source/ttl cache - shows changes eventually +persistent data store/redis/store mode read-write - no data source/infinite cache - shows changes never +persistent data store/redis/store mode read - no data source/no cache - shows changes immediately +persistent data store/redis/store mode read - no data source/ttl cache - shows changes eventually +persistent data store/redis/store mode read - no data source/infinite cache - shows changes never +persistent data store/dynamodb/uses default prefix +persistent data store/dynamodb/uses custom prefix +persistent data store/dynamodb/store mode read-write - no data source/no cache - shows changes immediately +persistent data store/dynamodb/store mode read-write - no data source/ttl cache - shows changes eventually +persistent data store/dynamodb/store mode read-write - no data source/infinite cache - shows changes never +persistent data store/dynamodb/store mode read - no data source/no cache - shows changes immediately +persistent data store/dynamodb/store mode read - no data source/ttl cache - shows changes eventually +persistent data store/dynamodb/store mode read - no data source/infinite cache - shows changes never From 14e1b1a0c99bc129cb996c082bece504a3faa387 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 24 Sep 2026 16:38:43 -0400 Subject: [PATCH 2/3] test: assume no initializer and no synchronizer is daemon --- .../contract-tests/src/sdkClientEntity.ts | 9 ++++++++ .../testharness-suppressions-fdv2.txt | 21 ------------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts index 5dc8a09d47..5c00acc728 100644 --- a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts +++ b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts @@ -292,6 +292,15 @@ export async function makeSdkConfig( const handle = await makePersistentStore(persistentDataStore); cf.dataSystem.persistentStore = handle.store; closeStore = handle.close; + // A store with zero initializers and zero synchronizers is the harness's + // daemon-mode configuration: the SDK reads from the store and starts + // no data source of its own. + if ( + dataSourceOptions.initializers.length === 0 && + dataSourceOptions.synchronizers.length === 0 + ) { + cf.dataSystem.useLdd = true; + } } // FDv1Fallback configures the SDK's FDv1 Fallback Synchronizer -- engaged only in diff --git a/packages/sdk/server-node/contract-tests/testharness-suppressions-fdv2.txt b/packages/sdk/server-node/contract-tests/testharness-suppressions-fdv2.txt index 22a34b9a08..22c1f2830c 100644 --- a/packages/sdk/server-node/contract-tests/testharness-suppressions-fdv2.txt +++ b/packages/sdk/server-node/contract-tests/testharness-suppressions-fdv2.txt @@ -11,24 +11,3 @@ streaming/fdv2/reconnection state management/replaces previously known state streaming/fdv2/reconnection state management/updates previously known state streaming/fdv2/ignores model version streaming/fdv2/can discard partial events on errors - -# These tests configure a persistent store with no data sources (the daemon-mode -# pattern; the prefix tests also create clients with zero sources). The FDv2 data -# system cannot yet initialize from a store read alone, so these clients never -# report ready. Remove these entries when that gap is closed. -persistent data store/redis/uses default prefix -persistent data store/redis/uses custom prefix -persistent data store/redis/store mode read-write - no data source/no cache - shows changes immediately -persistent data store/redis/store mode read-write - no data source/ttl cache - shows changes eventually -persistent data store/redis/store mode read-write - no data source/infinite cache - shows changes never -persistent data store/redis/store mode read - no data source/no cache - shows changes immediately -persistent data store/redis/store mode read - no data source/ttl cache - shows changes eventually -persistent data store/redis/store mode read - no data source/infinite cache - shows changes never -persistent data store/dynamodb/uses default prefix -persistent data store/dynamodb/uses custom prefix -persistent data store/dynamodb/store mode read-write - no data source/no cache - shows changes immediately -persistent data store/dynamodb/store mode read-write - no data source/ttl cache - shows changes eventually -persistent data store/dynamodb/store mode read-write - no data source/infinite cache - shows changes never -persistent data store/dynamodb/store mode read - no data source/no cache - shows changes immediately -persistent data store/dynamodb/store mode read - no data source/ttl cache - shows changes eventually -persistent data store/dynamodb/store mode read - no data source/infinite cache - shows changes never From 1ab4fa3f5f02bd3f14905a955ab484157acd8d4b Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 29 Sep 2026 13:44:36 -0400 Subject: [PATCH 3/3] fix: Correct the DynamoDB contract tests --- .../sdk/server-node/contract-tests/src/sdkClientEntity.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts index 5c00acc728..d06a1d09bf 100644 --- a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts +++ b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts @@ -142,6 +142,11 @@ async function makePersistentStore( secretAccessKey: 'dummy', sessionToken: 'dummy', }, + // The harness simulates short outages with a TCP proxy. The AWS + // client retries transient failures internally, so a retry can + // succeed after the proxy recovers and hide the outage from the + // SDK's write-failure trigger. + maxAttempts: 1, }, prefix: params.store.prefix, cacheTTL,