Skip to content

chore: Add support for persistent store contract tests. - #502

Open
kinyoklion wants to merge 18 commits into
mainfrom
rlamb/persistence-contract-tests
Open

chore: Add support for persistent store contract tests.#502
kinyoklion wants to merge 18 commits into
mainfrom
rlamb/persistence-contract-tests

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Nov 3, 2025

Copy link
Copy Markdown
Member

Runs the harness persistent data store suite against the C++ server SDK's Lazy Load data system, for Redis and DynamoDB, under both harness versions.

  • Contract test service accepts the store config from either location the harness uses — top level persistentDataStore (v2) or dataSystem.store.persistentDataStore (v3) — and builds a LazyLoadBuilder from it, picking a source by store.type.
  • Sources are compiled in conditionally (LD_REDIS_SUPPORT_ENABLED / LD_DYNAMODB_SUPPORT_ENABLED) and the matching persistent-data-store-* capability is advertised only when present.
  • Each store workflow is a matrix over harness v2/v3, filtered to -run=persistent.data.store so it doesn't duplicate the suites libs/server-sdk already runs.
  • Coverage per store: 14 run / 12 suppressed on v2, 15 run / 19 suppressed on v3. Suppressed cases are read-write and (v3) with data source — both SDK gaps, not test-service gaps.
Implementation details

Cache modes

offCacheRefresh(0s), ttlCacheRefresh(ttl seconds), infinite → a very large TTL (there is no dedicated "forever" API).

DynamoDB

Follows the convention the other SDKs' test services use: table sdk-contract-tests, the DSN is the endpoint, region us-east-1, dummy static credentials (ignored by DynamoDB Local).

Build/CI

  • scripts/build.sh takes a $5 DynamoDB toggle mirroring the existing $4 Redis toggle; the shared CI action exposes use_dynamodb. Positional args are quoted in the action so an unset use_redis doesn't shift the DynamoDB argument into its slot.
  • server-dynamodb.yml gains a contract-test job against amazon/dynamodb-local; it needs install_curl: true because the AWS C++ SDK links libcurl.
  • The redis contract-tests-curl job was dropped: the HTTP backend has no bearing on store behavior, and libs/server-sdk already covers curl.

Why tests remain suppressed

  • Read-write: the store integrations are read-only sources (ISerializedDataReader); nothing writes received data back to the store.
  • v3 with data source (both store modes): DataSystemBuilder's method is a variant of Lazy Load / Background Sync / FDv2, so a store cannot be combined with a data source.
  • Consul is modeled in the config type but has no C++ source, so the capability isn't advertised.
  • $inited handling comes from fix: allow lazy load evaluations when $inited key is not set #508 (already on main), so this branch no longer carries its own CanEvaluateWhenNotInitialized() change; ignores database initialization flag passes.

Testing

./scripts/build.sh server-tests ON false false true      # DynamoDB
docker run -d -p 8000:8000 amazon/dynamodb-local
./build/contract-tests/server-contract-tests/server-tests 8123 &
sdk-test-harness -url http://localhost:8123 -enable-persistence-tests \
  -run=persistent.data.store \
  -skip-from=contract-tests/server-contract-tests/persistence-suppressions.txt

Locally against DynamoDB Local: v2 14 ran, v3 15 ran (with persistence-suppressions-fdv2.txt), all passing. Redis is covered by CI; it doesn't build in this sandbox due to a local hiredis/redis++ fetch issue unrelated to this change.

No UI surface, so no screenshots or staging link apply.

Link to Devin session: https://app.devin.ai/sessions/115b0bb6b38349f7bd3e69e35d62d559
Requested by: @kinyoklion

kinyoklion and others added 5 commits November 3, 2025 15:34
Co-Authored-By: rlamb@launchdarkly.com <kingdewman@gmail.com>
Co-Authored-By: rlamb@launchdarkly.com <kingdewman@gmail.com>
…s CI

Co-Authored-By: rlamb@launchdarkly.com <kingdewman@gmail.com>
@kinyoklion

Copy link
Copy Markdown
Member Author

Devin, please update with the latest changes from main.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Done — merged latest main (includes PR #505 and #506 changes) into this branch. Waiting for CI now.

@kinyoklion
kinyoklion marked this pull request as ready for review March 10, 2026 21:29
@kinyoklion
kinyoklion requested a review from a team as a code owner March 10, 2026 21:29
cursor[bot]

This comment was marked as resolved.

Removed enable_persistence_tests and use_redis from server.yml
contract tests (which have no Redis service). Added dedicated
contract-tests and contract-tests-curl jobs to server-redis.yml
with a Redis service container and enable_persistence_tests: true.

Co-Authored-By: rlamb@launchdarkly.com <kingdewman@gmail.com>
cursor[bot]

This comment was marked as resolved.

The default of 'false' was always passed as $4 to build.sh, which
explicitly set build_redis='OFF' and overrode the auto-detection
for Redis target names. An empty default means $4 is empty, so
neither branch matches and auto-detection is preserved.

Co-Authored-By: rlamb@launchdarkly.com <kingdewman@gmail.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

devin-ai-integration Bot and others added 2 commits March 12, 2026 22:47
…sts support

The pinned SHA (contract-tests-v1.1.0) did not have the
enable_persistence_tests input, causing persistence tests to be
skipped. Updated to contract-tests-v1.3.0 which supports this input.

Co-Authored-By: rlamb@launchdarkly.com <kingdewman@gmail.com>
The Go test harness sends cache TTL as 'ttl' (in seconds), but the C++
data model expected 'ttlMs' (in milliseconds). This meant the TTL value
was never deserialized, and the default 5-minute cache TTL was always
used regardless of what the test requested.

- Rename ConfigPersistentCache::ttlMs to ttl to match JSON field name
- Change CacheRefresh from milliseconds to seconds to match harness units

Co-Authored-By: rlamb@launchdarkly.com <kingdewman@gmail.com>
} else if (in.persistentDataStore->cache.mode == "ttl") {
if (in.persistentDataStore->cache.ttl) {
lazy_load.CacheRefresh(std::chrono::seconds(
*in.persistentDataStore->cache.ttl));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TTL value likely treated as wrong time unit

High Severity

The CacheRefresh API accepts std::chrono::milliseconds, and the LaunchDarkly SDK contract test harness protocol sends the ttl value in milliseconds. However, the code wraps the raw ttl integer in std::chrono::seconds, which implicitly converts to milliseconds by multiplying by 1000. This makes the effective cache TTL 1000× larger than intended (e.g., a harness-sent value of 30000 ms becomes 30,000 seconds instead of 30 seconds). The data model comment also incorrectly states the unit is seconds. The value likely needs to be wrapped in std::chrono::milliseconds instead.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ttl (number, optional): If the cache mode is ttl, this value will be the time-to-live for cache entries in seconds.

In lazy load / daemon mode, the SDK's Initialized() check was blocking
all flag evaluations when the $inited key was not found in the
persistent store. This is problematic because in daemon mode, an
external process (like Relay Proxy) populates the store, and the
$inited key may not always be present.

The fix changes LazyLoad::Initialized() to always return true,
allowing evaluations to proceed using available data. When the
underlying source reports not initialized ($inited key not found),
a warning is logged to alert operators that a Relay Proxy or other
SDK should set this key.

This aligns with the Go SDK behavior where daemon mode
(ExternalUpdatesOnly) always considers the data source initialized.

Updated unit tests to reflect the new behavior and added tests
verifying the warning is logged appropriately.

Co-Authored-By: rlamb@launchdarkly.com <kingdewman@gmail.com>
devin-ai-integration Bot and others added 2 commits March 13, 2026 00:23
Reworked approach based on review feedback: Initialized() should
return false when $inited is not set (consistent with other SDK
implementations), and the evaluation path should handle this case
by warning and proceeding rather than blocking.

Changes:
- Added CanEvaluateWhenNotInitialized() virtual method to IDataSystem
  interface (defaults to false)
- LazyLoad overrides to return true (can serve on demand)
- PreEvaluationChecks warns and proceeds when data system can evaluate
  while not initialized, instead of returning CLIENT_NOT_READY
- AllFlagsState similarly warns and proceeds instead of returning empty
- Reverted LazyLoad::Initialized() to original behavior (truthfully
  reports whether $inited key exists)
- Added unit test for CanEvaluateWhenNotInitialized()

This matches the pattern used in the Erlang SDK where the evaluation
path distinguishes between 'not initialized' (blocks) and
'store initialized' (warns but proceeds).

Co-Authored-By: rlamb@launchdarkly.com <kingdewman@gmail.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Comment thread libs/server-sdk/src/client_impl.cpp Outdated
@kinyoklion
kinyoklion marked this pull request as draft March 13, 2026 22:14
Co-Authored-By: rlamb@launchdarkly.com <kingdewman@gmail.com>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently this SDK doesn't support read-write mode. It supports a purely lazy system for use with daemon mode.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With FDv2 support we will want to standardize this with the FDv2 behavior.

kinyoklion added a commit that referenced this pull request Aug 14, 2026
## Summary

In lazy load (daemon) mode the SDK gated all evaluations on the
persistent store's `$inited` key, so a store populated by the Relay
Proxy or another SDK that hadn't set `$inited` caused every evaluation
to return `CLIENT_NOT_READY`. This makes `LazyLoad` always report
initialized and evaluate on demand.

- `Initialized()` now always returns `true` — the lazy load system can
fetch data on demand from the store, so it is always ready to evaluate.
- `Initialize()` sets `kValid` immediately, and logs a one-time warning
if the store's `$inited` key is absent (evaluations still proceed).
- Removed the now-unused `RefreshInitState()`, `initialized_` member,
and `Keys::kInitialized`.
- Tests updated to cover always-true `Initialized()`, immediate `kValid`
status, and the warning-on-missing-`$inited` behavior.

No changes to `client_impl.cpp`: since `Initialized()` returns `true`,
`PreEvaluationChecks` and `AllFlagsState` proceed with their existing
logic.

## Design note

`LazyLoad` is the persistent-store (daemon) data system and never runs
alongside a streaming/polling data source — streaming uses the in-memory
Background Sync / FDv2 systems. There is therefore no configuration
where `$inited` needs to gate evaluation, so returning `true`
unconditionally is safe. This matches the server-side contract tests
(the "no data source" persistence tests populate the store's `features`
without `$inited` and still expect real evaluations) and the
Go/Java/.NET daemon-mode pattern, where a `NullDataSource` reports
initialized immediately.

## Behavioral change

When the store is completely unreachable, evaluations now proceed and
return the default with `FLAG_NOT_FOUND` rather than `CLIENT_NOT_READY`,
consistent with treating the system as always initialized.

## Testing

Not built locally (CMake toolchain unavailable); relying on CI for the
first compile. Follow-up: run the daemon-mode persistent-store contract
tests against Redis end-to-end after this and #502 land (this PR fixes
the initialization gating; #502 has the TTL field-name fix).

[Link to Devin
session](https://app.devin.ai/sessions/20776de6453c4e8baa2e4486dfe9aaba)
| Requested by: @kinyoklion

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes core readiness gating for daemon/persistent-store evaluations;
unreachable stores now surface flag-not-found defaults instead of
CLIENT_NOT_READY, which is intentional but affects operational failure
modes.
> 
> **Overview**
> **Lazy load (daemon) mode no longer blocks evaluations on the
persistent store’s `$inited` key.** Previously missing `$inited` could
leave the client not ready and return `CLIENT_NOT_READY`; evaluations
can proceed using on-demand reads from the store.
> 
> `LazyLoad::Initialized()` **always returns `true`**, and
`Initialize()` **always moves the data source to `kValid`** after a
one-time check of the reader. If the store lacks `$inited`, the SDK
**logs a warning** but still treats the system as ready—aligned with
Go/Java/.NET daemon behavior.
> 
> The **TTL-based init refresh path is removed** (`RefreshInitState`,
`initialized_`, and the `initialized` expiration key), and tests now
assert always-true initialization, immediate `kValid`, and
warning/no-warning logging.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
5da2bd3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration Bot and others added 3 commits August 14, 2026 21:39
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
@kinyoklion
kinyoklion marked this pull request as ready for review August 21, 2026 17:11
- cron: '0 8 * * *'

jobs:
contract-tests:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Is this redundant redundant with the existing contract test CI runner?
  2. There is currently one workflow for v2 tests, and one for v3, which includes fdv2. But I think these new workflows only cover the v2 tests without fdv2. If we add redis/dynamodb as a second dimension, should we set up a test matrix with all the combinations, or...?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both good catches — addressed in 8b4f028.

  1. It was partly redundant: the store jobs also re-ran the whole non-persistence v2 suite that libs/server-sdk already runs. They now pass -run=persistent.data.store, so each job only runs the persistence suite (the store-specific dimension). I also dropped the redis contract-tests-curl job — the HTTP backend has no bearing on store behavior and libs/server-sdk already covers curl.

  2. Each store workflow is now a matrix over harness v2/v3, which required a code fix: the v3 harness nests the store config under dataSystem.store.persistentDataStore (plus storeMode), so previously every v3 persistence test failed. The service now reads either location. Current per-store results:

harness run suppressed
v2 14 12
v3 15 19

The v3 suppressions (persistence-suppressions-fdv2.txt) are the read-write tests plus all with data source cases — the data system method is a variant (Lazy Load or Background Sync or FDv2), so a store can't be combined with a data source, and the store integrations are read-only sources. Both are SDK gaps rather than harness/test-service gaps.

One caveat with -run: if the harness ever renames the persistent data store group, these jobs would pass while running zero tests.

Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
@kinyoklion

Copy link
Copy Markdown
Member Author

Devin, I want you to verify that the builds pass the contract tests when you enable CURL networking for the SDK. This is to verify there isn't a conflict with internal use of CURL from other packages. Don't add it to the CI matrix at this time.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Devin, I want you to verify that the builds pass the contract tests when you enable CURL networking for the SDK.

Verified locally with LD_CURL_NETWORKING=ON + LD_BUILD_DYNAMODB_SUPPORT=ON, so the AWS C++ SDK's own libcurl HTTP client and the SDK's curl networking backend are linked into the same binary. No CI matrix change.

Build: clean, no symbol or link conflicts — both resolve to the system libcurl 7.81.0 (Http client: Curl for the AWS SDK, CURL networking enabled for internal/SSE).

Persistence suite against DynamoDB Local (-run=persistent.data.store), curl-enabled service:

harness ran skipped result
v2 14 25 all passed
v3 15 33 all passed

Full suites against the same curl-enabled service: v2 4509 ran, v3 4506 ran (with test-suppressions-fdv2.txt + persistence fdv2 suppressions). Both had the same 10 failures, all events/debug events/.../debugEventsUntilDate is after SDK time/SDK knows LD time is after debugEventsUntilDate/*, which reproduce identically on the non-curl build here — environmental (clock), not curl-related; the existing contract-tests-curl job is green in CI.

Not covered: Redis + curl, since redis-plus-plus/hiredis doesn't resolve in this sandbox (noted earlier in the PR). Redis links no curl of its own, so the AWS SDK is the only third-party curl consumer in play.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Follow-up: Redis + curl is now covered too. My earlier local hiredis failure was not a fetch/network issue — the sandbox had CMake 3.22, and OVERRIDE_FIND_PACKAGE (used in cmake/redis-plus-plus.cmake for hiredis) requires CMake ≥ 3.24. Below that, redis-plus-plus' FindHiredis.cmake falls through to a system search and configure fails with HIREDIS_HEADER-NOTFOUND. With CMake 4.4 the same configure succeeds unchanged; CI runners are already ≥ 3.24, hence green there.

LD_BUILD_REDIS_SUPPORT=ON + LD_CURL_NETWORKING=ON, against redis:latest, -run=persistent.data.store:

harness ran skipped result
v2 14 25 all passed
v3 15 33 all passed

So both stores pass the persistence suite with curl networking enabled. Still no CI matrix change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants