Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions Sources/eppo/ConfigurationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class ConfigurationStore {
private var debugLogger: ((String) -> Void)?

private let cacheFileURL: URL?
private let sdkKey: String?
// This is a serial (non-concurrent) queue, so writers don't fight
// each other and last writer wins.
//
Expand All @@ -22,9 +23,10 @@ class ConfigurationStore {
label: "cloud.eppo.configurationStorePersistence", qos: .background)

// Initialize with the disk-based path for storage
public init(withPersistentCache: Bool = true) {
public init(withPersistentCache: Bool = true, sdkKey: String? = nil) {
self.sdkKey = sdkKey
self.cacheFileURL = if withPersistentCache {
Self.findCacheFileURL()
Self.findCacheFileURL(sdkKey: sdkKey)
} else {
nil
}
Expand All @@ -44,7 +46,7 @@ class ConfigurationStore {
self.debugLogger = logger
}

private static func findCacheFileURL() -> URL? {
private static func findCacheFileURL(sdkKey: String? = nil) -> URL? {
guard let cacheDirectoryURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first?
.appendingPathComponent("eppo", isDirectory: true) else {
Expand All @@ -65,8 +67,19 @@ class ConfigurationStore {
return nil
}

// Make the cache file SDK-key specific to prevent configuration crossover between environments
let fileName: String
if let sdkKey = sdkKey {
// Use a hash of the SDK key to create a unique but deterministic filename
let sdkKeyHash = sdkKey.hash
fileName = "eppo-configuration-\(abs(sdkKeyHash)).json"
} else {
// Fallback to original filename for backward compatibility
fileName = "eppo-configuration.json"
}

return cacheDirectoryURL
.appendingPathComponent("eppo-configuration.json", isDirectory: false)
.appendingPathComponent(fileName, isDirectory: false)
}

// Get the configuration for a given flag key in a thread-safe manner.
Expand All @@ -89,8 +102,8 @@ class ConfigurationStore {
}
}

public static func clearPersistentCache() {
guard let cacheFileURL = Self.findCacheFileURL() else {
public static func clearPersistentCache(sdkKey: String? = nil) {
guard let cacheFileURL = Self.findCacheFileURL(sdkKey: sdkKey) else {
return
}

Expand Down
45 changes: 27 additions & 18 deletions Sources/eppo/EppoClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public class EppoClient {
let httpClient = NetworkEppoHttpClient(baseURL: self.host, sdkKey: self.sdkKey.token, sdkName: sdkName, sdkVersion: sdkVersion)
self.configurationRequester = ConfigurationRequester(httpClient: httpClient)

self.configurationStore = ConfigurationStore(withPersistentCache: withPersistentCache)
self.configurationStore = ConfigurationStore(withPersistentCache: withPersistentCache, sdkKey: self.sdkKey.token)
if let configuration = initialConfiguration {
self.configurationStore.setConfiguration(configuration: configuration)
// Note: Callbacks will be registered after init, so initial config callback will be triggered during loadIfNeeded
Expand Down Expand Up @@ -103,26 +103,35 @@ public class EppoClient {
debugCallback: ((String, Double, Double) -> Void)? = nil
) -> EppoClient {
return sharedLock.withLock {
// Check if there's an existing instance and if the SDK key has changed
if let instance = sharedInstance {
return instance
} else {
let instance = EppoClient(
sdkKey: sdkKey,
host: host,
assignmentLogger: assignmentLogger,
assignmentCache: assignmentCache,
initialConfiguration: initialConfiguration,
withPersistentCache: withPersistentCache,
debugCallback: debugCallback
)

if let callback = configurationChangeCallback {
instance.onConfigurationChange(callback)
if instance.sdkKey.token == sdkKey {
// Same SDK key, return existing instance with its configuration cache
return instance
} else {
// Different SDK key, reset the shared instance to create a new one
// This ensures each SDK key (environment) has its own configuration cache
sharedInstance = nil
}

sharedInstance = instance
return instance
}

// Create new instance (either no existing instance or SDK key changed)
let instance = EppoClient(
sdkKey: sdkKey,
host: host,
assignmentLogger: assignmentLogger,
assignmentCache: assignmentCache,
initialConfiguration: initialConfiguration,
withPersistentCache: withPersistentCache,
debugCallback: debugCallback
)

if let callback = configurationChangeCallback {
instance.onConfigurationChange(callback)
}

sharedInstance = instance
return instance
}
}

Expand Down
207 changes: 207 additions & 0 deletions Tests/eppo/ConfigurationCacheEnvironmentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import XCTest
import Foundation
import OHHTTPStubs
import OHHTTPStubsSwift
@testable import EppoFlagging

final class ConfigurationCacheEnvironmentTests: XCTestCase {
var UFCTestJSON: String!

override func setUpWithError() throws {
try super.setUpWithError()

EppoClient.resetSharedInstance()
ConfigurationStore.clearPersistentCache()

let fileURL = Bundle.module.url(
forResource: "Resources/test-data/ufc/flags-v1-obfuscated.json",
withExtension: ""
)
UFCTestJSON = try! String(contentsOfFile: fileURL!.path)

// Set up HTTP stubs for API calls
stub(condition: isHost("fscdn.eppo.cloud")) { _ in
let stubData = self.UFCTestJSON.data(using: .utf8)!
return HTTPStubsResponse(data: stubData, statusCode: 200, headers: ["Content-Type": "application/json"])
}
}

override func tearDownWithError() throws {
HTTPStubs.removeAllStubs()
ConfigurationStore.clearPersistentCache()
EppoClient.resetSharedInstance()
try super.tearDownWithError()
}

func testConfigurationCacheRespectsDifferentSDKKeys() async throws {
// This test verifies the fix for: https://github.com/Eppo-exp/eppo-ios-sdk/issues/83
// Configuration cache should be environment-specific based on SDK keys

// Step 1: Initialize with first SDK key (Production)
let prodClient = try await EppoClient.initialize(
sdkKey: "prod-sdk-key-12345",
withPersistentCache: true
)

let prodConfig = prodClient.getFlagsConfiguration()
XCTAssertNotNil(prodConfig, "Production configuration should be loaded")

// Verify production cache file exists
let prodCacheFile = getCacheFileURL(sdkKey: "prod-sdk-key-12345")
Thread.sleep(forTimeInterval: 0.2) // Wait for async file write
XCTAssertTrue(FileManager.default.fileExists(atPath: prodCacheFile.path),
"Production should have its own cache file")

// Step 2: Switch to staging environment (different SDK key)
// Customer should NOT need to call resetSharedInstance()
let stagingClient = try await EppoClient.initialize(
sdkKey: "staging-sdk-key-67890",
withPersistentCache: true
)

let stagingConfig = stagingClient.getFlagsConfiguration()
XCTAssertNotNil(stagingConfig, "Staging configuration should be loaded")

// Verify staging has its own separate cache file
let stagingCacheFile = getCacheFileURL(sdkKey: "staging-sdk-key-67890")
Thread.sleep(forTimeInterval: 0.2) // Wait for async file write
XCTAssertTrue(FileManager.default.fileExists(atPath: stagingCacheFile.path),
"Staging should have its own cache file")

// Critical: Verify cache files are different (environment isolation)
XCTAssertNotEqual(prodCacheFile.path, stagingCacheFile.path,
"Different SDK keys should have different cache files")

// Step 3: Switch back to production - should load production cache
let prodClient2 = try await EppoClient.initialize(
sdkKey: "prod-sdk-key-12345",
withPersistentCache: true
)

let prodConfig2 = prodClient2.getFlagsConfiguration()
XCTAssertNotNil(prodConfig2, "Production configuration should load from cache")

// Verify both cache files still exist
XCTAssertTrue(FileManager.default.fileExists(atPath: prodCacheFile.path),
"Production cache should persist")
XCTAssertTrue(FileManager.default.fileExists(atPath: stagingCacheFile.path),
"Staging cache should persist")
}

func testConfigurationCacheRespectsDifferentSDKKeysOffline() throws {
// Test offline initialization with different SDK keys

// Environment A configuration
let envAConfig = """
{
"format": "SERVER",
"createdAt": "2024-04-17T19:40:53.716Z",
"environment": { "name": "EnvironmentA" },
"flags": {
"test_flag": {
"key": "test_flag",
"enabled": true,
"variationType": "STRING",
"variations": { "control": { "key": "control", "value": "envA_value" } },
"allocations": [{ "key": "allocation1", "doLog": true, "splits": [{ "variationKey": "control", "shards": [] }] }],
"totalShards": 10000
}
}
}
"""

let configA = try Configuration(flagsConfigurationJson: Data(envAConfig.utf8), obfuscated: false)

// Initialize with SDK key A
let clientA = EppoClient.initializeOffline(
sdkKey: "offline-sdk-key-A",
initialConfiguration: configA,
withPersistentCache: true
)

let loadedConfigA = clientA.getFlagsConfiguration()
XCTAssertEqual(loadedConfigA?.getFlagConfigDetails().configEnvironment.name, "EnvironmentA")

// Verify cache file A exists
let cacheFileA = getCacheFileURL(sdkKey: "offline-sdk-key-A")
Thread.sleep(forTimeInterval: 0.1)
XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFileA.path))

// Environment B configuration
let envBConfig = """
{
"format": "SERVER",
"createdAt": "2024-04-17T19:40:53.716Z",
"environment": { "name": "EnvironmentB" },
"flags": {
"test_flag": {
"key": "test_flag",
"enabled": true,
"variationType": "STRING",
"variations": { "control": { "key": "control", "value": "envB_value" } },
"allocations": [{ "key": "allocation1", "doLog": true, "splits": [{ "variationKey": "control", "shards": [] }] }],
"totalShards": 10000
}
}
}
"""

let configB = try Configuration(flagsConfigurationJson: Data(envBConfig.utf8), obfuscated: false)

// Switch to SDK key B
let clientB = EppoClient.initializeOffline(
sdkKey: "offline-sdk-key-B",
initialConfiguration: configB,
withPersistentCache: true
)

let loadedConfigB = clientB.getFlagsConfiguration()
XCTAssertEqual(loadedConfigB?.getFlagConfigDetails().configEnvironment.name, "EnvironmentB")

// Verify cache file B exists and is different from A
let cacheFileB = getCacheFileURL(sdkKey: "offline-sdk-key-B")
Thread.sleep(forTimeInterval: 0.1)
XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFileB.path))
XCTAssertNotEqual(cacheFileA.path, cacheFileB.path)

// Both cache files should still exist (environment isolation)
XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFileA.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFileB.path))
}

func testSameSDKKeyReusesCacheCorrectly() async throws {
// Verify that the same SDK key correctly reuses its cache file

let client1 = try await EppoClient.initialize(
sdkKey: "same-sdk-key",
withPersistentCache: true
)

let config1 = client1.getFlagsConfiguration()
XCTAssertNotNil(config1)

let cacheFile = getCacheFileURL(sdkKey: "same-sdk-key")
Thread.sleep(forTimeInterval: 0.2)
XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFile.path))

// Reinitialize with same SDK key - should reuse existing instance and cache
let client2 = try await EppoClient.initialize(
sdkKey: "same-sdk-key",
withPersistentCache: true
)

let config2 = client2.getFlagsConfiguration()
XCTAssertNotNil(config2)

// Should still have the same cache file
XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFile.path))
}

private func getCacheFileURL(sdkKey: String) -> URL {
let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
let eppoDirectory = cacheDirectory.appendingPathComponent("eppo", isDirectory: true)
let sdkKeyHash = sdkKey.hash
let fileName = "eppo-configuration-\(abs(sdkKeyHash)).json"
return eppoDirectory.appendingPathComponent(fileName, isDirectory: false)
}
}
Loading