From 5174d9051e849cba59ae728116ad45591f87bb72 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Mon, 27 Jul 2026 17:50:09 +0300 Subject: [PATCH 1/4] feat: configurable ttl for targeting cache --- OptableSDK.xcodeproj/project.pbxproj | 1 + Source/Core/LocalStorage.swift | 26 ++++++++ Source/OptableSDK.swift | 4 +- Source/Public/OptableConfig.swift | 19 +++++- Tests/Unit/LocalStorageTests.swift | 99 ++++++++++++++++++++++++++++ Tests/Unit/OptableConfigTests.swift | 32 +++++++++ docs/usage-objc.md | 14 ++++ docs/usage-swift.md | 13 ++++ 8 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 Tests/Unit/OptableConfigTests.swift diff --git a/OptableSDK.xcodeproj/project.pbxproj b/OptableSDK.xcodeproj/project.pbxproj index fab643e..686c972 100644 --- a/OptableSDK.xcodeproj/project.pbxproj +++ b/OptableSDK.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ Misc/Constants.swift, Unit/EdgeAPITests.swift, Unit/LocalStorageTests.swift, + Unit/OptableConfigTests.swift, Unit/OptableIdentifierEncoderTests.swift, Unit/OptableIdentifiersTests.swift, Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift, diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index 2b3016a..8ee9ead 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -16,10 +16,12 @@ final class LocalStorage: NSObject { private let gamTargetingKeywordsKey: String private let ortb2Key: String private let id5SignatureKey: String + private let config: OptableConfig let keyPfx: String = "OPTABLE" var passportKey: String var targetingKey: String + var targetingStoredAtKey: String init(_ config: OptableConfig) { // The key used for storage should be unique to the host+app that this instance was initialized with: @@ -28,6 +30,8 @@ final class LocalStorage: NSObject { .data(using: .utf8)? .base64EncodedString() + self.config = config + self.passportKey = self.keyPfx + "_PASS_" + (base64Key ?? "UNKNOWN") self.targetingKey = self.keyPfx + "_TGT_" + (base64Key ?? "UNKNOWN") @@ -35,6 +39,7 @@ final class LocalStorage: NSObject { self.gamTargetingKeywordsKey = targetingKey + "_gamTargetingKeywords" self.ortb2Key = targetingKey + "_ortb2" self.id5SignatureKey = targetingKey + "_id5Signature" + self.targetingStoredAtKey = targetingKey + "_storedAt" } func getPassport() -> String? { @@ -49,11 +54,18 @@ final class LocalStorage: NSObject { guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? [String: Any] else { return nil } + + guard isTargetingFresh() else { + clearTargeting() + return nil + } + let optableTargeting = OptableTargeting( optableTargeting: targetingData, gamTargetingKeywords: UserDefaults.standard.object(forKey: gamTargetingKeywordsKey) as? [String: Any], ortb2: UserDefaults.standard.string(forKey: ortb2Key) ) + return optableTargeting } @@ -64,6 +76,7 @@ final class LocalStorage: NSObject { UserDefaults.standard.setValue(targeting.targetingData, forKey: targetingDataKey) UserDefaults.standard.setValue(targeting.gamTargetingKeywords, forKey: gamTargetingKeywordsKey) UserDefaults.standard.setValue(targeting.ortb2, forKey: ortb2Key) + UserDefaults.standard.setValue(Date().timeIntervalSince1970, forKey: targetingStoredAtKey) } func clearTargeting() { @@ -71,6 +84,7 @@ final class LocalStorage: NSObject { UserDefaults.standard.removeObject(forKey: gamTargetingKeywordsKey) UserDefaults.standard.removeObject(forKey: ortb2Key) UserDefaults.standard.removeObject(forKey: id5SignatureKey) + UserDefaults.standard.removeObject(forKey: targetingStoredAtKey) } func getID5Signature() -> String? { @@ -80,4 +94,16 @@ final class LocalStorage: NSObject { func setID5Signature(_ signature: String?) { UserDefaults.standard.set(signature, forKey: id5SignatureKey) } + + /// Whether the stored targeting entry was fetched recently enough to still be served, per `config.cacheTTL`. + private func isTargetingFresh() -> Bool { + // NOTE: A missing timestamp means the entry predates cache expiry support, so its age is unknowable - treat it as expired. + guard let storedAt = UserDefaults.standard.object(forKey: targetingStoredAtKey) as? TimeInterval else { + return false + } + + let age = Date().timeIntervalSince1970 - storedAt + + return age >= 0 && age <= config.cacheTTL + } } diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index c8625c5..308213f 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -142,7 +142,9 @@ public extension OptableSDK { try _targeting(ids: ids, hids: hids, completion: completion) } - /// targetingFromCache() returns the previously cached targeting data, if any. + /// Returns the previously cached targeting data, if any. + /// Cached data expires after `OptableConfig.cacheTTL` (24 hours by default). An expired entry is + /// reported as absent and is cleared from storage. @objc func targetingFromCache() -> OptableTargeting? { return self.api.storage.getTargeting() diff --git a/Source/Public/OptableConfig.swift b/Source/Public/OptableConfig.swift index 0d5a783..65b829e 100644 --- a/Source/Public/OptableConfig.swift +++ b/Source/Public/OptableConfig.swift @@ -10,6 +10,11 @@ import Foundation @objc public class OptableConfig: NSObject { + // MARK: Constants + /// The default lifetime of cached targeting data: 24 hours. + @objc + public static let defaultCacheTTL: TimeInterval = 24 * 60 * 60 + // MARK: Required /// The tenant name associated with the configuration. E.g. `acmeco.optable.co` => `acmeco`. @objc @@ -50,6 +55,15 @@ public class OptableConfig: NSObject { @objc public var skipAdvertisingIdDetection: Bool = false + /** + How long, in seconds, targeting data cached by the `targeting` API stays valid. Default is `defaultCacheTTL` (24 hours). + + Once a cached entry is older than this, `targetingFromCache()` reports it as absent and drops it from storage. + A value of `0` therefore disables caching entirely. + */ + @objc + public var cacheTTL: TimeInterval = OptableConfig.defaultCacheTTL + // MARK: Privacy Regulations /** Optable privacy regulation override, which can be one of: gdpr, can, us, or null and will override all other privacy regulations when present. @@ -111,6 +125,7 @@ public class OptableConfig: NSObject { - customUserAgent: An optional custom user agent string for network requests. - origin: An optional value sent as the `Origin` HTTP header on every Optable API request, identifying the origin you want your mobile traffic attributed to. E.g. `https://www.acmeco.com`. No header is sent when nil. Unrelated to `originSlug`. - skipAdvertisingIdDetection: Boolean flag to skip the detection of advertising IDs. Default is false. + - cacheTTL: How long, in seconds, cached targeting data stays valid. Default is `defaultCacheTTL` (24 hours). */ public init( tenant: String, @@ -121,7 +136,8 @@ public class OptableConfig: NSObject { apiKey: String? = nil, customUserAgent: String? = nil, origin: String? = nil, - skipAdvertisingIdDetection: Bool = false + skipAdvertisingIdDetection: Bool = false, + cacheTTL: TimeInterval = OptableConfig.defaultCacheTTL ) { self.tenant = tenant self.originSlug = originSlug @@ -132,5 +148,6 @@ public class OptableConfig: NSObject { self.customUserAgent = customUserAgent self.origin = origin self.skipAdvertisingIdDetection = skipAdvertisingIdDetection + self.cacheTTL = cacheTTL } } diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index f8e89ba..8b2848d 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -97,6 +97,105 @@ class LocalStorageTests: XCTestCase { XCTAssert(localStorage.getTargeting() == nil) } + + // MARK: - Cache TTL + func testTargetingIsReturnedWithinTTL() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + setStoredAge(storage, to: 30) + + XCTAssertNotNil(storage.getTargeting()) + } + + func testTargetingIsNilPastTTL() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + setStoredAge(storage, to: 61) + + XCTAssertNil(storage.getTargeting()) + } + + func testExpiredTargetingIsClearedFromStorage() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + setStoredAge(storage, to: 61) + XCTAssertNil(storage.getTargeting()) + + setStoredAge(storage, to: 0) + XCTAssertNil(storage.getTargeting()) + } + + func testTargetingWithoutStoredTimestampIsTreatedAsExpired() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + UserDefaults.standard.removeObject(forKey: storage.targetingStoredAtKey) + + XCTAssertNil(storage.getTargeting()) + } + + func testTargetingIsNilWhenStoredInTheFuture() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + setStoredAge(storage, to: -30) + + XCTAssertNil(storage.getTargeting()) + } + + func testDefaultTTLKeepsTargetingFreshJustUnderTwentyFourHours() { + let storage = makeStorageWithStoredTargeting(cacheTTL: nil) + + setStoredAge(storage, to: 24 * 60 * 60 - 60) + + XCTAssertNotNil(storage.getTargeting()) + } + + func testDefaultTTLExpiresTargetingPastTwentyFourHours() { + let storage = makeStorageWithStoredTargeting(cacheTTL: nil) + + setStoredAge(storage, to: 24 * 60 * 60 + 60) + + XCTAssertNil(storage.getTargeting()) + } + + // MARK: Helpers + /** + Builds a LocalStorage with targeting already stored in it. + + Each call uses a unique tenant so that tests never share UserDefaults keys. + Passing a nil `cacheTTL` leaves the config default in place. + */ + private func makeStorageWithStoredTargeting( + cacheTTL: TimeInterval?, + function: String = #function + ) -> LocalStorage { + let config = OptableConfig(tenant: "tenant-\(function)", originSlug: "slug") + if let cacheTTL { + config.cacheTTL = cacheTTL + } + + let storage = LocalStorage(config) + storage.setTargeting( + OptableTargeting( + optableTargeting: kOptableTargeting as! [String: Any], + gamTargetingKeywords: kGamTargetingKeywords as? [String: Any], + ortb2: kORTB2 + ) + ) + + return storage + } + + /** + Backdates the stored entry so that it reads as `age` seconds old, standing in for the passage of time. + + A negative age places the timestamp in the future. + */ + private func setStoredAge(_ storage: LocalStorage, to age: TimeInterval) { + UserDefaults.standard.setValue( + Date().timeIntervalSince1970 - age, + forKey: storage.targetingStoredAtKey + ) + } } private let kOptableTargeting: NSDictionary = [ diff --git a/Tests/Unit/OptableConfigTests.swift b/Tests/Unit/OptableConfigTests.swift new file mode 100644 index 0000000..9bedd73 --- /dev/null +++ b/Tests/Unit/OptableConfigTests.swift @@ -0,0 +1,32 @@ +// +// OptableConfigTests.swift +// OptableSDK +// +// Copyright © 2026 Optable Technologies, Inc. All rights reserved. +// + +@testable import OptableSDK +import XCTest + +// MARK: - OptableConfigTests +class OptableConfigTests: XCTestCase { + func testDefaultCacheTTLIsTwentyFourHours() { + XCTAssertEqual(OptableConfig.defaultCacheTTL, 24 * 60 * 60) + } + + func testCacheTTLDefaultsToDefaultCacheTTL() { + let objcInit = OptableConfig(tenant: "tenant", originSlug: "slug") + XCTAssertEqual(objcInit.cacheTTL, OptableConfig.defaultCacheTTL) + + let swiftInit = OptableConfig(tenant: "tenant", originSlug: "slug", host: "host") + XCTAssertEqual(swiftInit.cacheTTL, OptableConfig.defaultCacheTTL) + } + + func testCacheTTLIsConfigurable() { + let config = OptableConfig(tenant: "tenant", originSlug: "slug", cacheTTL: 60) + XCTAssertEqual(config.cacheTTL, 60) + + config.cacheTTL = 120 + XCTAssertEqual(config.cacheTTL, 120) + } +} diff --git a/docs/usage-objc.md b/docs/usage-objc.md index 9ca87a9..4262d33 100644 --- a/docs/usage-objc.md +++ b/docs/usage-objc.md @@ -181,6 +181,20 @@ You can also clear the locally cached targeting data: Note that both `targetingFromCache` and `targetingClearCache` are synchronous. +##### Cache Expiry + +Cached targeting data expires 24 hours after it was fetched. Once an entry has expired, `targetingFromCache` reports it as absent by returning `nil`, and clears it from client storage. Call the targeting API again to refresh it. + +You can change the lifetime by setting the `cacheTTL` property, expressed in seconds: + +```objective-c +@import OptableSDK; +... +config.cacheTTL = 60 * 60; // expire cached targeting data after one hour +``` + +The default is `OptableConfig.defaultCacheTTL`, which is 24 hours. Setting `cacheTTL` to `0` effectively disables the cache, since every entry is then already expired by the time it is read. + ### Witness API To send real-time event data from the user's device to the DCN for eventual audience assembly, you can call the witness API as follows: diff --git a/docs/usage-swift.md b/docs/usage-swift.md index a66a225..2a66bbe 100644 --- a/docs/usage-swift.md +++ b/docs/usage-swift.md @@ -195,6 +195,19 @@ OPTABLE!.targetingClearCache() Note that both `targetingFromCache()` and `targetingClearCache()` are synchronous. +##### Cache Expiry + +Cached targeting data expires 24 hours after it was fetched. Once an entry has expired, `targetingFromCache()` reports it as absent by returning `nil`, and clears it from client storage. Call `targeting()` again to refresh it. + +You can change the lifetime with the optional `cacheTTL` parameter, expressed in seconds: + +```swift +let config = OptableConfig(..., cacheTTL: 60 * 60) // expire cached targeting data after one hour +OPTABLE = OptableSDK(config: config) +``` + +The default is `OptableConfig.defaultCacheTTL`, which is 24 hours. Setting `cacheTTL` to `0` effectively disables the cache, since every entry is then already expired by the time it is read. + ### Witness API > :information_source: For more info check: From 010ad1fb475e1d5605f71f9d012c66664dfa65b3 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 31 Jul 2026 14:23:43 +0300 Subject: [PATCH 2/4] fix: store timestamp first --- Source/Core/LocalStorage.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index 8ee9ead..11c815c 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -73,10 +73,10 @@ final class LocalStorage: NSObject { // Decompose object explicitly // Because Codable/NSSecureCoding does not support heterogeneous containers such as NSDictionary([String: Any]) // However UserDefaults does support + UserDefaults.standard.setValue(Date().timeIntervalSince1970, forKey: targetingStoredAtKey) UserDefaults.standard.setValue(targeting.targetingData, forKey: targetingDataKey) UserDefaults.standard.setValue(targeting.gamTargetingKeywords, forKey: gamTargetingKeywordsKey) UserDefaults.standard.setValue(targeting.ortb2, forKey: ortb2Key) - UserDefaults.standard.setValue(Date().timeIntervalSince1970, forKey: targetingStoredAtKey) } func clearTargeting() { From 12fa8fa9b61d3cfcfa82cff71d7831c6c5c4e9ac Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 31 Jul 2026 15:45:12 +0300 Subject: [PATCH 3/4] fix: cache expiry boundary consistency --- Source/Core/LocalStorage.swift | 2 +- Tests/Unit/LocalStorageTests.swift | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index 11c815c..1003c9a 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -104,6 +104,6 @@ final class LocalStorage: NSObject { let age = Date().timeIntervalSince1970 - storedAt - return age >= 0 && age <= config.cacheTTL + return age >= 0 && age < config.cacheTTL } } diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index 8b2848d..41404f5 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -115,6 +115,22 @@ class LocalStorageTests: XCTestCase { XCTAssertNil(storage.getTargeting()) } + func testTargetingExpiresAtExactlyTTL() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + + setStoredAge(storage, to: 60) + + XCTAssertNil(storage.getTargeting()) + } + + func testZeroTTLDisablesCaching() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 0) + + setStoredAge(storage, to: 0) + + XCTAssertNil(storage.getTargeting()) + } + func testExpiredTargetingIsClearedFromStorage() { let storage = makeStorageWithStoredTargeting(cacheTTL: 60) From 41e88348618c2cb8dc78071a15d7891d82e70163 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 4 Sep 2026 13:17:29 +0300 Subject: [PATCH 4/4] fix: race condition in set clear path --- Source/Core/LocalStorage.swift | 70 +++++++++++++++++------------- Source/Misc/NSLock++.swift | 18 ++++++++ Tests/Unit/LocalStorageTests.swift | 31 +++++++++++++ 3 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 Source/Misc/NSLock++.swift diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index 1003c9a..5ba0d73 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -16,12 +16,15 @@ final class LocalStorage: NSObject { private let gamTargetingKeywordsKey: String private let ortb2Key: String private let id5SignatureKey: String - private let config: OptableConfig let keyPfx: String = "OPTABLE" var passportKey: String var targetingKey: String var targetingStoredAtKey: String + + private let config: OptableConfig + + private let lock = NSLock() init(_ config: OptableConfig) { // The key used for storage should be unique to the host+app that this instance was initialized with: @@ -51,51 +54,58 @@ final class LocalStorage: NSObject { } func getTargeting() -> OptableTargeting? { - guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? [String: Any] else { - return nil - } - - guard isTargetingFresh() else { - clearTargeting() - return nil + lock.synchronized { + guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? [String: Any] else { + return nil + } + + guard isTargetingFresh() else { + removeTargetingEntry() + return nil + } + + return OptableTargeting( + optableTargeting: targetingData, + gamTargetingKeywords: UserDefaults.standard.object(forKey: gamTargetingKeywordsKey) as? [String: Any], + ortb2: UserDefaults.standard.string(forKey: ortb2Key) + ) } - - let optableTargeting = OptableTargeting( - optableTargeting: targetingData, - gamTargetingKeywords: UserDefaults.standard.object(forKey: gamTargetingKeywordsKey) as? [String: Any], - ortb2: UserDefaults.standard.string(forKey: ortb2Key) - ) - - return optableTargeting } func setTargeting(_ targeting: OptableTargeting) { - // Decompose object explicitly - // Because Codable/NSSecureCoding does not support heterogeneous containers such as NSDictionary([String: Any]) - // However UserDefaults does support - UserDefaults.standard.setValue(Date().timeIntervalSince1970, forKey: targetingStoredAtKey) - UserDefaults.standard.setValue(targeting.targetingData, forKey: targetingDataKey) - UserDefaults.standard.setValue(targeting.gamTargetingKeywords, forKey: gamTargetingKeywordsKey) - UserDefaults.standard.setValue(targeting.ortb2, forKey: ortb2Key) + lock.synchronized { + // Decompose object explicitly + // Because Codable/NSSecureCoding does not support heterogeneous containers such as NSDictionary([String: Any]) + // However UserDefaults does support + UserDefaults.standard.setValue(Date().timeIntervalSince1970, forKey: targetingStoredAtKey) + UserDefaults.standard.setValue(targeting.targetingData, forKey: targetingDataKey) + UserDefaults.standard.setValue(targeting.gamTargetingKeywords, forKey: gamTargetingKeywordsKey) + UserDefaults.standard.setValue(targeting.ortb2, forKey: ortb2Key) + } } func clearTargeting() { - UserDefaults.standard.removeObject(forKey: targetingDataKey) - UserDefaults.standard.removeObject(forKey: gamTargetingKeywordsKey) - UserDefaults.standard.removeObject(forKey: ortb2Key) - UserDefaults.standard.removeObject(forKey: id5SignatureKey) - UserDefaults.standard.removeObject(forKey: targetingStoredAtKey) + lock.synchronized { removeTargetingEntry() } } func getID5Signature() -> String? { - return UserDefaults.standard.string(forKey: id5SignatureKey) + UserDefaults.standard.string(forKey: id5SignatureKey) } func setID5Signature(_ signature: String?) { UserDefaults.standard.set(signature, forKey: id5SignatureKey) } - /// Whether the stored targeting entry was fetched recently enough to still be served, per `config.cacheTTL`. + /// Removes every key of the targeting entry. + private func removeTargetingEntry() { + UserDefaults.standard.removeObject(forKey: targetingDataKey) + UserDefaults.standard.removeObject(forKey: gamTargetingKeywordsKey) + UserDefaults.standard.removeObject(forKey: ortb2Key) + UserDefaults.standard.removeObject(forKey: id5SignatureKey) + UserDefaults.standard.removeObject(forKey: targetingStoredAtKey) + } + + /// Whether the stored targeting entry was fetched recently enough to still be served, per `config.cacheTTL`. The caller must hold `lock`. private func isTargetingFresh() -> Bool { // NOTE: A missing timestamp means the entry predates cache expiry support, so its age is unknowable - treat it as expired. guard let storedAt = UserDefaults.standard.object(forKey: targetingStoredAtKey) as? TimeInterval else { diff --git a/Source/Misc/NSLock++.swift b/Source/Misc/NSLock++.swift new file mode 100644 index 0000000..9c1a350 --- /dev/null +++ b/Source/Misc/NSLock++.swift @@ -0,0 +1,18 @@ +// +// NSLock++.swift +// OptableSDK +// +// Copyright © 2026 Optable Technologies, Inc. All rights reserved. +// + +import Foundation + +extension NSLock { + /// Runs `body` while holding the lock and returns its result. + /// Stands in for `NSLock.withLock(_:)`, which requires iOS 16. + func synchronized(_ body: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index 41404f5..9f87e1d 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -173,6 +173,37 @@ class LocalStorageTests: XCTestCase { XCTAssertNil(storage.getTargeting()) } + // MARK: - Thread safety + /** + A stale read must never wipe an entry that was stored concurrently. + + Without mutual exclusion, `getTargeting()` can judge the old entry expired, then lose the CPU to a + `setTargeting(_:)` that stores a fresh one, then resume and clear it. Whichever order the two calls + serialize in, a fresh entry must be readable afterwards. + */ + func testConcurrentReadOfExpiredEntryDoesNotWipeFreshWrite() { + let storage = makeStorageWithStoredTargeting(cacheTTL: 60) + let freshTargeting = OptableTargeting( + optableTargeting: kOptableTargeting as! [String: Any], + gamTargetingKeywords: kGamTargetingKeywords as? [String: Any], + ortb2: kORTB2 + ) + + for iteration in 0..<500 { + storage.setTargeting(freshTargeting) + setStoredAge(storage, to: 61) + + let group = DispatchGroup() + let queue = DispatchQueue.global(qos: .userInitiated) + queue.async(group: group) { _ = storage.getTargeting() } + queue.async(group: group) { storage.setTargeting(freshTargeting) } + group.wait() + + XCTAssertNotNil(storage.getTargeting(), "fresh entry was wiped by a concurrent stale read on iteration \(iteration)") + if storage.getTargeting() == nil { break } + } + } + // MARK: Helpers /** Builds a LocalStorage with targeting already stored in it.