Conversation
6d13e79 to
038d23b
Compare
49d422f to
9cbec58
Compare
9cbec58 to
dfcb351
Compare
There was a problem hiding this comment.
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.cacheKeyPrefixand 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.
dfcb351 to
236e412
Compare
236e412 to
7dd8284
Compare
There was a problem hiding this comment.
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 viaself.cacheService(which may be prefixed), but then constructsanonUserviaDevCycleUser.builder()which uses an unprefixedCacheServiceinternally to generate/persist the anon id. WithcacheKeyPrefixset this can repopulate the legacyANONYMOUS_USER_IDkey 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’scacheService(same prefix) and building theDevCycleUserfrom 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.
7dd8284 to
8f843f4
Compare
8f843f4 to
15c6a42
Compare
15c6a42 to
68610a7
Compare
There was a problem hiding this comment.
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
cacheKeyPrefixis used,resetUserreads/clears the anonymous ID viaself.cacheService(which is prefixed), but then creates the new anonymous user viaDevCycleUser.builder()which internally uses an un-prefixedCacheService()forgetOrCreateAnonUserId(). 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 intoUserBuilder, or avoidUserBuilder’s persisted anon-id path insideresetUser).
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.
68610a7 to
b6cf252
Compare
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
b6cf252 to
06257a9
Compare
There was a problem hiding this comment.
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 incacheServicebut does not restorelastIdentifiedUsertopreviousUser. SincelastIdentifiedUserwas already set to the newly-generatedanonUser(with a different userId afterclearAnonUserId()), the client can end up with inconsistent state after a failed reset and future refreshes may use the wrong user context. Consider restoringlastIdentifiedUser(and any other derived state) on all error paths, not just definitive errors, or deferring thelastIdentifiedUser = anonUserassignment 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.
06257a9 to
a8c2241
Compare
a8c2241 to
aed6f42
Compare
…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
There was a problem hiding this comment.
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, whengetConfigreturns a non-definitive error you restore the previous anonymous user ID but you do not restorelastIdentifiedUsertopreviousUserbefore returning. That leaves the client in an inconsistent state (userremains the previous user, butlastIdentifiedUseris the new anonymous user), which can cause subsequentrefetchConfig/background refreshes to run under the wrong user context. RestorelastIdentifiedUser(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.
aed6f42 to
132b1c6
Compare
jsalaber
left a comment
There was a problem hiding this comment.
as per the ADR this pr should include the new cache-mode option
| } | ||
| } | ||
|
|
||
| var isDefinitiveError: Bool { |
There was a problem hiding this comment.
what's definitive mean in this case?
There was a problem hiding this comment.
Non-retryable server rejections, where retrying won't help.
| } | ||
|
|
||
| func clearConfigForUser(user: DevCycleUser) { | ||
| // TODO: update implementation for tests |
There was a problem hiding this comment.
should this be done as part of the pr?
There was a problem hiding this comment.
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.
aa4cee7 to
132b1c6
Compare
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
DevCycleClient.setup→performBackgroundRefreshDevCycleService.APIError.isDefinitiveErrorperformBackgroundRefreshidentifyUser/resetUser: evict that user's cache, restore previous useridentifyUser/resetUser+Cache.clearConfigForUseronConfigUpdated/refetchConfigNew public API
onConfigUpdated(_:)— refresh observer; the most recent pre-registration result is buffered and replayed once to a late registrant so handlers attached aftersetup()still see that first event.hasUsableCachedConfig()—truebetween 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.