Skip to content

feat: cache first initialization#261

Open
luxscious wants to merge 5 commits intomainfrom
ICP-2289-cache-first-initialization
Open

feat: cache first initialization#261
luxscious wants to merge 5 commits intomainfrom
ICP-2289-cache-first-initialization

Conversation

@luxscious
Copy link
Copy Markdown
Contributor

@luxscious luxscious commented Apr 10, 2026

Summary

On cold start, initialize() now resolves immediately from a persisted config when one is available for the current user, and refreshes from the network in the background. The flash-of-defaults at every launch goes away, and transient network failures stop blocking the SDK from coming up.

Identity changes (identifyUser / resetUser) get matching error semantics: definitive auth/config errors invalidate the new user's cache and roll back to the previous user, while transient errors keep the cached values usable and refresh in the background.

What changed

Area Where
Cache-hit fast path + background refresh DevCycleClient.setupperformBackgroundRefresh
Definitive (400/401/403) vs transient API errors DevCycleService.APIError.isDefinitiveError
Definitive error mid-refresh: keep cache, notify observers performBackgroundRefresh
Definitive error mid-identifyUser / resetUser: evict that user's cache, restore previous user identifyUser / resetUser + Cache.clearConfigForUser
Observers notified on every refresh (incl. SSE-pushed) onConfigUpdated / refetchConfig

New public API

  • onConfigUpdated(_:) — refresh observer; the most recent pre-registration result is buffered and replayed once to a late registrant so handlers attached after setup() still see that first event.
  • hasUsableCachedConfig()true between cache load and first successful refresh.

Inspiration / prior art

The definitive-vs-transient split and the broader cold-start shape are cross-referenced against OpenFeature ADR 0009.

@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch 7 times, most recently from 6d13e79 to 038d23b Compare April 14, 2026 14:48
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch 5 times, most recently from 49d422f to 9cbec58 Compare April 28, 2026 15:19
@luxscious luxscious marked this pull request as ready for review April 28, 2026 15:23
@luxscious luxscious requested a review from a team as a code owner April 28, 2026 15:23
Copilot AI review requested due to automatic review settings April 28, 2026 15:23
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from 9cbec58 to dfcb351 Compare April 28, 2026 15:29
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements ADR 0009 “cache-first initialization” for the DevCycle client so cold starts can initialize from persisted config immediately and then refresh in the background, reducing “flash-of-defaults” while adding refresh observers and cache isolation support.

Changes:

  • Add cache-hit fast path in DevCycleClient.setup() with background refresh + onConfigUpdated(_:) observer API.
  • Introduce definitive error classification (400/401/403) and use it to control cache eviction/retention across refresh + user changes.
  • Add cache key namespacing support via DevCycleOptions.cacheKeyPrefix and update cache service behavior + tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
DevCycle/DevCycleClient.swift Cache-first setup path, background refresh, definitive error handling, config update observers, and “CACHED” eval marking.
DevCycle/Models/Cache.swift Adds per-user cache eviction and key prefixing for config storage keys.
DevCycle/Models/DevCycleOptions.swift Adds cacheKeyPrefix option to namespace persisted cache keys.
DevCycle/Networking/DevCycleService.swift Adds APIError.isDefinitiveError classification for ADR 0009 behavior.
DevCycle/Models/UserConfig.swift Adds EvalReason.withReason to mark cached evaluations as CACHED.
DevCycleTests/Models/DevCycleClientTests.swift Adds cache-first initialization + background refresh + observer behavior tests.
DevCycleTests/Models/DevCycleUserTest.swift Makes TTL test less flaky by increasing TTL and wait time.
DevCycleTests/Networking/DevCycleServiceTests.swift Updates cache mock to conform to expanded cache protocol.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread DevCycle/DevCycleClient.swift
Comment thread DevCycle/DevCycleClient.swift
Comment thread DevCycle/Models/Cache.swift Outdated
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from dfcb351 to 236e412 Compare April 28, 2026 15:37
Copilot AI review requested due to automatic review settings April 28, 2026 16:05
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from 236e412 to 7dd8284 Compare April 28, 2026 16:05
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

DevCycle/DevCycleClient.swift:601

  • resetUser() clears the anonymous id via self.cacheService (which may be prefixed), but then constructs anonUser via DevCycleUser.builder() which uses an unprefixed CacheService internally to generate/persist the anon id. With cacheKeyPrefix set this can repopulate the legacy ANONYMOUS_USER_ID key and produce a userId that doesn’t match the client’s namespaced storage, defeating multi-instance isolation. Consider generating the anon id using the client’s cacheService (same prefix) and building the DevCycleUser from that, or otherwise ensuring the builder uses the same namespace.
        let previousUser = self.lastIdentifiedUser
        let cachedAnonUserId = self.cacheService.getAnonUserId()
        self.cacheService.clearAnonUserId()
        let anonUser = try DevCycleUser.builder().isAnonymous(true).build()

        self.lastIdentifiedUser = anonUser

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread DevCycle/DevCycleClient.swift
Comment thread DevCycle/Models/Cache.swift
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from 7dd8284 to 8f843f4 Compare April 28, 2026 17:15
Comment thread DevCycle/Models/Cache.swift Outdated
Comment thread DevCycle/Models/DevCycleOptions.swift Outdated
Copilot AI review requested due to automatic review settings April 29, 2026 14:32
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from 8f843f4 to 15c6a42 Compare April 29, 2026 14:32
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from 15c6a42 to 68610a7 Compare April 29, 2026 14:33
@luxscious luxscious requested a review from jsalaber April 29, 2026 14:36
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

DevCycle/DevCycleClient.swift:600

  • When cacheKeyPrefix is used, resetUser reads/clears the anonymous ID via self.cacheService (which is prefixed), but then creates the new anonymous user via DevCycleUser.builder() which internally uses an un-prefixed CacheService() for getOrCreateAnonUserId(). This means resetUser can end up generating/using a global (unprefixed) anonymous ID and leave the prefixed anonymous ID unset, undermining multi-instance isolation and making the client’s cached anon-id state inconsistent. Consider plumbing the client’s cache namespace into anonymous user ID generation (e.g., inject a cache service/prefix into UserBuilder, or avoid UserBuilder’s persisted anon-id path inside resetUser).
        let previousUser = self.lastIdentifiedUser
        let cachedAnonUserId = self.cacheService.getAnonUserId()
        self.cacheService.clearAnonUserId()
        let anonUser = try DevCycleUser.builder().isAnonymous(true).build()

        self.lastIdentifiedUser = anonUser

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread DevCycle/DevCycleClient.swift Outdated
Comment thread DevCycle/DevCycleClient.swift
Comment thread DevCycle/DevCycleClient.swift Outdated
Comment thread DevCycle/DevCycleClient.swift Outdated
Comment thread DevCycle/Models/Cache.swift Outdated
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from 68610a7 to b6cf252 Compare April 29, 2026 15:18
Auth/configuration errors at HTTP 400/401/403 won't recover from a retry,
while network/server errors usually do. Splitting them lets cache-first
init decide whether to keep the cached config usable on background-refresh
failures and whether explicit identity changes should invalidate the new
user's cache.

Adds APIError.isDefinitiveError as the single source of truth for that
distinction.

Inspired by: OpenFeature ADR 0009.

Made-with: Cursor
CacheService can already save and read a per-user config; the missing
piece is invalidation. Adds CacheServiceProtocol.clearConfigForUser(user:)
and its implementation so the client can drop a specific user's persisted
config without touching anyone else's entries — used by the upcoming
identifyUser/resetUser definitive-error paths.

Made-with: Cursor
Copilot AI review requested due to automatic review settings April 30, 2026 17:32
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from b6cf252 to 06257a9 Compare April 30, 2026 17:32
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

DevCycle/DevCycleClient.swift:623

  • In resetUser, the transient-error path restores the previous anonymous user ID in cacheService but does not restore lastIdentifiedUser to previousUser. Since lastIdentifiedUser was already set to the newly-generated anonUser (with a different userId after clearAnonUserId()), the client can end up with inconsistent state after a failed reset and future refreshes may use the wrong user context. Consider restoring lastIdentifiedUser (and any other derived state) on all error paths, not just definitive errors, or deferring the lastIdentifiedUser = anonUser assignment until after a successful fetch.
                    // Transient error: only restore the previous anon ID (matches pre-existing behavior).
                    if let previousAnonUserId = cachedAnonUserId {
                        self.cacheService.setAnonUserId(anonUserId: previousAnonUserId)
                    }
                    callback?(error, nil)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread DevCycle/Models/Cache.swift
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from 06257a9 to a8c2241 Compare April 30, 2026 18:28
Copilot AI review requested due to automatic review settings April 30, 2026 18:49
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from a8c2241 to aed6f42 Compare April 30, 2026 18:49
…ated observer

Cold-start was previously gated on a network round trip — every launch
paid full latency before any flag could be read. With this change, when
a usable config is already persisted for the current user, setup()
resolves immediately from cache and the network refresh runs in the
background; on cache miss, the prior network-first behavior is preserved.

- isConfigCached flag + hasUsableCachedConfig() public accessor
- deliverInitializationComplete() helper standardizes setup completion
  on the main queue and drains queued config-completion handlers exactly
  once
- performBackgroundRefresh() splits error handling: definitive
  (400/401/403) keeps cached values usable and notifies observers so the
  app can react; transient errors keep the cache silently
- onConfigUpdated(_:) observer API with first-event replay buffer so
  handlers attached after setup() has already kicked off a background
  refresh still receive that first event (registration race)

Inspired by: OpenFeature ADR 0009.

Made-with: Cursor
Identity changes are user-initiated and definitive — silently serving
another user's stale data on auth/config failure is the wrong default.
This is the deliberate inverse of the background-refresh path, which
keeps cached values usable on transient errors.

- identifyUser: on definitive error (HTTP 400/401/403), clear the new
  user's persisted config and restore lastIdentifiedUser to the previous
  user. previousUser is captured at entry so any error path can roll back
  cleanly.
- identifyUser: on transient error with a usable cached config, kick off
  a background refresh so the new user's data eventually reaches freshness
  without blocking the caller.
- resetUser: on definitive error, clear the new anonymous user's cache,
  restore the previous anon user ID, and restore lastIdentifiedUser.
  Brings resetUser to parity with identifyUser.

Inspired by: OpenFeature ADR 0009.

Made-with: Cursor
Adds DevCycleClientTests coverage for the cache-first initialization
path:
- Cache-hit fast path resolves before network completion
- Definitive auth error on background refresh keeps cached values
  usable in-session and emits onConfigUpdated(error:)
- Transient error keeps cache silently with no event
- identifyUser definitive error clears the new user's cache and
  restores the previous user
- onConfigUpdated fires whether registered before setup() or after
  the refresh has already landed (registration-race regression)

Also stabilizes DevCycleUserTest.testConfigCacheTTLRespected for
loaded CI runners (100ms TTL was flaky; bumped to 2s).

Made-with: Cursor
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

DevCycle/DevCycleClient.swift:626

  • In resetUser, when getConfig returns a non-definitive error you restore the previous anonymous user ID but you do not restore lastIdentifiedUser to previousUser before returning. That leaves the client in an inconsistent state (user remains the previous user, but lastIdentifiedUser is the new anonymous user), which can cause subsequent refetchConfig/background refreshes to run under the wrong user context. Restore lastIdentifiedUser (and any other mutated state) on all error/early-return paths where the reset does not actually complete.
                    if let previousAnonUserId = cachedAnonUserId {
                        self.cacheService.setAnonUserId(anonUserId: previousAnonUserId)
                    }
                    callback?(error, nil)
                    return

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread DevCycleTests/Networking/DevCycleServiceTests.swift
Comment thread DevCycle/DevCycleClient.swift
Comment thread DevCycleTests/Models/DevCycleClientTests.swift
Comment thread DevCycleTests/Models/DevCycleClientTests.swift
Comment thread DevCycleTests/Models/DevCycleClientTests.swift
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from aed6f42 to 132b1c6 Compare April 30, 2026 19:09
Copy link
Copy Markdown
Collaborator

@jsalaber jsalaber left a comment

Choose a reason for hiding this comment

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

as per the ADR this pr should include the new cache-mode option

}
}

var isDefinitiveError: Bool {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

what's definitive mean in this case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Non-retryable server rejections, where retrying won't help.

}

func clearConfigForUser(user: DevCycleUser) {
// TODO: update implementation for tests
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

should this be done as part of the pr?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yea we can add it

Copilot AI review requested due to automatic review settings May 1, 2026 19:11
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread DevCycle/DevCycleClient.swift
Comment thread DevCycle/DevCycleClient.swift
Comment thread DevCycleTests/Models/DevCycleClientTests.swift
@luxscious luxscious force-pushed the ICP-2289-cache-first-initialization branch from aa4cee7 to 132b1c6 Compare May 8, 2026 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants