Skip to content
Open
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
1 change: 1 addition & 0 deletions OptableSDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 55 additions & 19 deletions Source/Core/LocalStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ final class LocalStorage: NSObject {
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:
Expand All @@ -28,13 +33,16 @@ 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")

self.targetingDataKey = targetingKey + "_targetingData"
self.gamTargetingKeywordsKey = targetingKey + "_gamTargetingKeywords"
self.ortb2Key = targetingKey + "_ortb2"
self.id5SignatureKey = targetingKey + "_id5Signature"
self.targetingStoredAtKey = targetingKey + "_storedAt"
}

func getPassport() -> String? {
Expand All @@ -46,38 +54,66 @@ final class LocalStorage: NSObject {
}

func getTargeting() -> OptableTargeting? {
guard let targetingData = UserDefaults.standard.object(forKey: targetingDataKey) as? [String: Any] else {
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)
)
}

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.

Have this concern from Claude that I believe is relevant. Race condition in the set / clear path.

  Failure scenario, expired entry present:

  1. Main thread targetingFromCache() reads targetingData (old), reads the old storedAt, decides expired.
  2. Background thread completes targeting() and runs setTargeting() — writes the new timestamp and all three data keys.
  3. Main thread resumes into clearTargeting() and removes all four keys.

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.

Oh this is already discussed above I did not read the comments. I will approve but this should be addressed in a followup

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

good catch! guarded set / clear path with NSLock

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(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)
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)
}

/// 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 {
return false
}

let age = Date().timeIntervalSince1970 - storedAt

return age >= 0 && age < config.cacheTTL
}
}
18 changes: 18 additions & 0 deletions Source/Misc/NSLock++.swift
Original file line number Diff line number Diff line change
@@ -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<T>(_ body: () throws -> T) rethrows -> T {
lock()
defer { unlock() }
return try body()
}
}
4 changes: 3 additions & 1 deletion Source/OptableSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 18 additions & 1 deletion Source/Public/OptableConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,6 +49,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.
Expand Down Expand Up @@ -104,6 +118,7 @@ public class OptableConfig: NSObject {
- apiKey: An optional API key for authentication. If the API Endpoint is enabled as private, a Service Account API key will be required.
- customUserAgent: An optional custom user agent string for network requests.
- 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,
Expand All @@ -113,7 +128,8 @@ public class OptableConfig: NSObject {
insecure: Bool = false,
apiKey: String? = nil,
customUserAgent: String? = nil,
skipAdvertisingIdDetection: Bool = false
skipAdvertisingIdDetection: Bool = false,
cacheTTL: TimeInterval = OptableConfig.defaultCacheTTL
) {
self.tenant = tenant
self.originSlug = originSlug
Expand All @@ -123,5 +139,6 @@ public class OptableConfig: NSObject {
self.apiKey = apiKey
self.customUserAgent = customUserAgent
self.skipAdvertisingIdDetection = skipAdvertisingIdDetection
self.cacheTTL = cacheTTL
}
}
146 changes: 146 additions & 0 deletions Tests/Unit/LocalStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,152 @@ 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 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)

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: - 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.

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 = [
Expand Down
32 changes: 32 additions & 0 deletions Tests/Unit/OptableConfigTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading