Skip to content
Merged
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
8 changes: 8 additions & 0 deletions Split.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,8 @@
C53207EC2EF44A2F00418BB1 /* PersistenceBreaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207EA2EF44A2F00418BB1 /* PersistenceBreaker.swift */; };
C53207EE2EF452C000418BB1 /* CoreDataHelperStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207ED2EF452C000418BB1 /* CoreDataHelperStub.swift */; };
C53207F12EF456AF00418BB1 /* CoreDataHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207F02EF456AF00418BB1 /* CoreDataHelperTests.swift */; };
C53207F42EF45BC600418BB1 /* PersistentSplitsStorageTransactionalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207F22EF45BC600418BB1 /* PersistentSplitsStorageTransactionalTests.swift */; };
C53207F52EF45BC600418BB1 /* SplitsPersistenceBreakerIntegrationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53207F32EF45BC600418BB1 /* SplitsPersistenceBreakerIntegrationTest.swift */; };
C539CAB62D88C1F10050C732 /* RuleBasedSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CAB52D88C1F10050C732 /* RuleBasedSegment.swift */; };
C539CABE2D88C7410050C732 /* PersistentRuleBasedSegmentsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CABC2D88C7410050C732 /* PersistentRuleBasedSegmentsStorage.swift */; };
C539CABF2D88C7410050C732 /* RuleBasedSegmentsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C539CABD2D88C7410050C732 /* RuleBasedSegmentsStorage.swift */; };
Expand Down Expand Up @@ -2010,6 +2012,8 @@
C53207EA2EF44A2F00418BB1 /* PersistenceBreaker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceBreaker.swift; sourceTree = "<group>"; };
C53207ED2EF452C000418BB1 /* CoreDataHelperStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelperStub.swift; sourceTree = "<group>"; };
C53207F02EF456AF00418BB1 /* CoreDataHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelperTests.swift; sourceTree = "<group>"; };
C53207F22EF45BC600418BB1 /* PersistentSplitsStorageTransactionalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentSplitsStorageTransactionalTests.swift; sourceTree = "<group>"; };
C53207F32EF45BC600418BB1 /* SplitsPersistenceBreakerIntegrationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitsPersistenceBreakerIntegrationTest.swift; sourceTree = "<group>"; };
C539CAB52D88C1F10050C732 /* RuleBasedSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBasedSegment.swift; sourceTree = "<group>"; };
C539CABC2D88C7410050C732 /* PersistentRuleBasedSegmentsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentRuleBasedSegmentsStorage.swift; sourceTree = "<group>"; };
C539CABD2D88C7410050C732 /* RuleBasedSegmentsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBasedSegmentsStorage.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2687,6 +2691,8 @@
5905D4E3255B2373006DA3B1 /* Storage */ = {
isa = PBXGroup;
children = (
C53207F22EF45BC600418BB1 /* PersistentSplitsStorageTransactionalTests.swift */,
C53207F32EF45BC600418BB1 /* SplitsPersistenceBreakerIntegrationTest.swift */,
C53207F02EF456AF00418BB1 /* CoreDataHelperTests.swift */,
C53207E82EF44A2100418BB1 /* PersistenceBreakerTests.swift */,
C52C572A2EEB41450064D049 /* EncryptionKeyValidationTest.swift */,
Expand Down Expand Up @@ -4776,6 +4782,8 @@
592C6AC4211B718E002D120C /* RegexTest.swift in Sources */,
9500D9922C56F9BA00383593 /* HostDomainFilterTests.swift in Sources */,
95F3F0282590D81B00084AF8 /* ImpressionsRecorderWorkerTests.swift in Sources */,
C53207F42EF45BC600418BB1 /* PersistentSplitsStorageTransactionalTests.swift in Sources */,
C53207F52EF45BC600418BB1 /* SplitsPersistenceBreakerIntegrationTest.swift in Sources */,
955E12312BFBEA8600AE6D10 /* HashedImpressionDaoTest.swift in Sources */,
95342E992A4C71300045B201 /* FeatureFlagsPayloadDecoderMock.swift in Sources */,
9577A8472683B8C800D92AE1 /* HttpImpressionsCountRecorderTests.swift in Sources */,
Expand Down
17 changes: 13 additions & 4 deletions Split/Api/SplitDatabaseHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,13 @@ struct SplitDatabaseHelper {
DefaultFlagSetsCache(setsInFilter: splitClientConfig.bySetsFilter()?.values.asSet())
let persistentSplitsStorage = DefaultPersistentSplitsStorage(database: splitDatabase)
let generalInfoStorage = openGeneralInfoStorage(database: splitDatabase)
let splitsStorage = openSplitsStorage(database: splitDatabase, flagSetsCache: flagSetsCache, generalInfoStorage: generalInfoStorage)

// Create shared persistence breaker for targeting rules (splits + RBS)
let targetingRulesPersistenceBreaker = DefaultPersistenceBreaker()
let splitsStorage = openSplitsStorage(database: splitDatabase,
flagSetsCache: flagSetsCache,
generalInfoStorage: generalInfoStorage,
persistenceBreaker: targetingRulesPersistenceBreaker)

let persistentImpressionsStorage = openPersistentImpressionsStorage(database: splitDatabase)
let impressionsStorage = openImpressionsStorage(persistentStorage: persistentImpressionsStorage)
Expand Down Expand Up @@ -139,7 +145,8 @@ struct SplitDatabaseHelper {
hashedImpressionsStorage: hashedImpressionsStorage,
generalInfoStorage: generalInfoStorage,
ruleBasedSegmentsStorage: ruleBasedSegmentsStorage,
persistentRuleBasedSegmentsStorage: persistentRuleBasedSegmentsStorage)
persistentRuleBasedSegmentsStorage: persistentRuleBasedSegmentsStorage,
targetingRulesPersistenceBreaker: targetingRulesPersistenceBreaker)
}

static func openDatabase(dataFolderName: String,
Expand All @@ -158,9 +165,11 @@ struct SplitDatabaseHelper {
}

static func openSplitsStorage(database: SplitDatabase,
flagSetsCache: FlagSetsCache, generalInfoStorage: GeneralInfoStorage) -> SplitsStorage {
flagSetsCache: FlagSetsCache, generalInfoStorage: GeneralInfoStorage,
persistenceBreaker: PersistenceBreaker) -> SplitsStorage {
return DefaultSplitsStorage(persistentSplitsStorage: openPersistentSplitsStorage(database: database),
flagSetsCache: flagSetsCache, GeneralInfoStorage: generalInfoStorage)
flagSetsCache: flagSetsCache, generalInfoStorage: generalInfoStorage,
persistenceBreaker: persistenceBreaker)
}

static func openPersistentMySegmentsStorage(database: SplitDatabase) -> PersistentMySegmentsStorage {
Expand Down
1 change: 1 addition & 0 deletions Split/Network/Sync/SyncCommons.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct SplitStorageContainer {
let generalInfoStorage: GeneralInfoStorage
let ruleBasedSegmentsStorage: RuleBasedSegmentsStorage
let persistentRuleBasedSegmentsStorage: PersistentRuleBasedSegmentsStorage
let targetingRulesPersistenceBreaker: PersistenceBreaker
}

protocol ImpressionLogger {
Expand Down
34 changes: 28 additions & 6 deletions Split/Storage/Splits/PersistentSplitsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import Foundation

protocol PersistentSplitsStorage {
func update(splitChange: ProcessedSplitChange)
func update(splitChange: ProcessedSplitChange, onFailure: ((Error) -> Void)?)
func update(split: Split)
func update(bySetsFilter: SplitFilter?)
func getBySetsFilter() -> SplitFilter?
Expand All @@ -25,17 +25,39 @@ class DefaultPersistentSplitsStorage: PersistentSplitsStorage {

private let splitDao: SplitDao
private let generalInfoDao: GeneralInfoDao
private let coreDataHelper: CoreDataHelper

init(database: SplitDatabase) {
self.splitDao = database.splitDao
self.generalInfoDao = database.generalInfoDao
if let testDb = database as? TestSplitDatabase {
self.coreDataHelper = testDb.coreDataHelper
} else {
fatalError("Database must provide CoreDataHelper for transactional operations")
}
}

func update(splitChange: ProcessedSplitChange) {
splitDao.insertOrUpdate(splits: splitChange.activeSplits)
splitDao.delete(splitChange.archivedSplits.compactMap { return $0.name })
generalInfoDao.update(info: .splitsChangeNumber, longValue: splitChange.changeNumber)
generalInfoDao.update(info: .splitsUpdateTimestamp, longValue: splitChange.updateTimestamp)
func update(splitChange: ProcessedSplitChange, onFailure: ((Error) -> Void)? = nil) {
// Execute transactionally: all operations must succeed or all must fail
coreDataHelper.performAndWait { [weak self] in
guard let self = self else { return }

do {
// All operations within this block happen in the same CoreData context
self.splitDao.transactionalInsertOrUpdate(splits: splitChange.activeSplits)
let archivedNames = splitChange.archivedSplits.compactMap { $0.name }
self.splitDao.transactionalDelete(archivedNames)

self.generalInfoDao.transactionalUpdate(info: .splitsChangeNumber, longValue: splitChange.changeNumber)
self.generalInfoDao.transactionalUpdate(info: .splitsUpdateTimestamp, longValue: splitChange.updateTimestamp)

// Save everything as one transaction
try self.coreDataHelper.saveWithErrorHandling()
} catch {
Logger.e("Transactional flags update failed: \(error.localizedDescription)")
onFailure?(error)
}
}
}

func update(split: Split) {
Expand Down
4 changes: 2 additions & 2 deletions Split/Storage/Splits/SplitDao.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ protocol SplitDao {

/// Synchronous insert/update for use in transactions
/// Caller must call coreDataHelper.saveWithErrorHandling()
func transactionalInsertOrUpdate(splits: [Split]) throws
func transactionalInsertOrUpdate(splits: [Split])

/// Synchronous delete for use in transactions
/// Caller must call coreDataHelper.saveWithErrorHandling()
Expand Down Expand Up @@ -121,7 +121,7 @@ class CoreDataSplitDao: BaseCoreDataDao, SplitDao {
}

/// Synchronous insert/update that does NOT save
func transactionalInsertOrUpdate(splits: [Split]) throws {
func transactionalInsertOrUpdate(splits: [Split]) {
let parsed = self.encoder.encode(splits)
for (name, json) in parsed {
if let obj = self.getBy(name: name) ?? self.coreDataHelper.create(entity: .split) as? SplitEntity {
Expand Down
33 changes: 25 additions & 8 deletions Split/Storage/Splits/SplitsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,21 @@ class DefaultSplitsStorage: SplitsStorage {
private var trafficTypes: SynchronizedDictionary<String, Int>
private let flagSetsCache: FlagSetsCache
private let generalInfoStorage: GeneralInfoStorage
private let persistenceBreaker: PersistenceBreaker

private(set) var changeNumber: Int64 = -1
private(set) var updateTimestamp: Int64 = -1

init(persistentSplitsStorage: PersistentSplitsStorage,
flagSetsCache: FlagSetsCache,
GeneralInfoStorage: GeneralInfoStorage) {
generalInfoStorage: GeneralInfoStorage,
persistenceBreaker: PersistenceBreaker) {
self.persistentStorage = persistentSplitsStorage
self.inMemorySplits = SynchronizedDictionary()
self.trafficTypes = SynchronizedDictionary()
self.flagSetsCache = flagSetsCache
self.generalInfoStorage = GeneralInfoStorage
self.generalInfoStorage = generalInfoStorage
self.persistenceBreaker = persistenceBreaker
}

func loadLocal() {
Expand Down Expand Up @@ -86,26 +89,39 @@ class DefaultSplitsStorage: SplitsStorage {

func update(splitChange: ProcessedSplitChange) -> Bool {

// Process
// Process in-memory updates (always happens)
let updated = processUpdated(splits: splitChange.activeSplits, active: true)
let removed = processUpdated(splits: splitChange.archivedSplits, active: false)

// Update
// Update in-memory metadata (always happens)
changeNumber = splitChange.changeNumber
updateTimestamp = splitChange.updateTimestamp
persistentStorage.update(splitChange: splitChange)

// Attempt persistence only if breaker allows
if persistenceBreaker.isPersistenceEnabled {
persistentStorage.update(splitChange: splitChange, onFailure: { [weak self] _ in
// On first failure, disable persistence for remainder of session
self?.persistenceBreaker.disable()
})
}

return updated || removed
}

func update(bySetsFilter filter: SplitFilter?) {
self.persistentStorage.update(bySetsFilter: filter)
// Only call persistence if breaker allows
if persistenceBreaker.isPersistenceEnabled {
self.persistentStorage.update(bySetsFilter: filter)
}
}

func updateWithoutChecks(split: Split) {
if let splitName = split.name?.lowercased() {
inMemorySplits.setValue(split, forKey: splitName)
persistentStorage.update(split: split)
// Only call persistence if breaker allows
if persistenceBreaker.isPersistenceEnabled {
persistentStorage.update(split: split)
}
}
}

Expand Down Expand Up @@ -296,7 +312,8 @@ class BackgroundSyncSplitsStorage: SyncSplitsStorage {
}

func update(splitChange: ProcessedSplitChange) -> Bool {
persistentStorage.update(splitChange: splitChange)
// If persistence fails, it will be logged but won't trigger breaker
persistentStorage.update(splitChange: splitChange, onFailure: nil)
return true
}

Expand Down
2 changes: 1 addition & 1 deletion SplitTests/Fake/Storage/PersistentSplitsStorageStub.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class PersistentSplitsStorageStub: PersistentSplitsStorage {
self.init(delegate: nil)
}

func update(splitChange: ProcessedSplitChange) {
func update(splitChange: ProcessedSplitChange, onFailure: ((Error) -> Void)? = nil) {
processedSplitChange = splitChange
changeNumber = splitChange.changeNumber
updateCalled = true
Expand Down
2 changes: 1 addition & 1 deletion SplitTests/Fake/Storage/SplitDaoStub.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class SplitDaoStub: SplitDao {
deleteAllCalled = true
}

func transactionalInsertOrUpdate(splits: [Split]) throws {
func transactionalInsertOrUpdate(splits: [Split]) {
insertedSplits = splits
}

Expand Down
3 changes: 2 additions & 1 deletion SplitTests/Helpers/TestingHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ struct TestingHelper {
hashedImpressionsStorage: HashedImpressionsStorageMock(),
generalInfoStorage: GeneralInfoStorageMock(),
ruleBasedSegmentsStorage: RuleBasedSegmentsStorageStub(),
persistentRuleBasedSegmentsStorage: PersistentRuleBasedSegmentsStorageStub())
persistentRuleBasedSegmentsStorage: PersistentRuleBasedSegmentsStorageStub(),
targetingRulesPersistenceBreaker: DefaultPersistenceBreaker())
}

static func createApiFacade() -> SplitApiFacade {
Expand Down
2 changes: 1 addition & 1 deletion SplitTests/Storage/PersistentSplitsStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class PersistentSplitsStorageTest: XCTestCase {
let archivedSplits = [newSplit(name: "ar1", trafficType: "t2", status: .archived), newSplit(name: "ar2", trafficType: "t2", status: .archived)]
let change = ProcessedSplitChange(activeSplits: activeSplits, archivedSplits: archivedSplits, changeNumber: 100, updateTimestamp: 200)

splitsStorage.update(splitChange: change)
splitsStorage.update(splitChange: change, onFailure: nil)

XCTAssertEqual(3, splitDao.insertedSplits.count)
XCTAssertEqual(2, splitDao.deletedSplits?.count)
Expand Down
106 changes: 106 additions & 0 deletions SplitTests/Storage/PersistentSplitsStorageTransactionalTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//
// PersistentSplitsStorageTransactionalTests.swift
// SplitTests
//

import Foundation
import XCTest
@testable import Split

class PersistentSplitsStorageTransactionalTests: XCTestCase {

var splitsStorage: PersistentSplitsStorage!
var splitDao: SplitDaoStub!
var generalInfoDao: GeneralInfoDaoStub!
var coreDataHelperStub: CoreDataHelperStub!

override func setUp() {
splitDao = SplitDaoStub()
generalInfoDao = GeneralInfoDaoStub()
coreDataHelperStub = CoreDataHelperStub()
var daoProvider = CoreDataDaoProviderMock()
daoProvider.splitDao = splitDao
daoProvider.generalInfoDao = generalInfoDao
splitsStorage = DefaultPersistentSplitsStorage(
database: SplitDatabaseStub(daoProvider: daoProvider, coreDataHelper: coreDataHelperStub)
)
}

func testSuccessDoesNotInvokeFailureCallback() {
let change = ProcessedSplitChange(
activeSplits: [createSplit(name: "split1")],
archivedSplits: [],
changeNumber: 100,
updateTimestamp: 1000
)

var failureWasReported = false

splitsStorage.update(splitChange: change, onFailure: { _ in
failureWasReported = true
})

XCTAssertFalse(failureWasReported, "With working stubs, no failure should occur")
}

func testFailureCallbackIsInvokedOnSaveError() {
coreDataHelperStub.shouldFailOnSave = true

let change = ProcessedSplitChange(
activeSplits: [createSplit(name: "split1")],
archivedSplits: [],
changeNumber: 100,
updateTimestamp: 1000
)

var failureWasReported = false
var reportedError: Error?

splitsStorage.update(splitChange: change, onFailure: { error in
failureWasReported = true
reportedError = error
})

XCTAssertTrue(failureWasReported, "Failure callback should be invoked on save error")
XCTAssertNotNil(reportedError, "Error should be reported")
}

func testNilFailureCallbackIsHandled() {
let change = ProcessedSplitChange(
activeSplits: [createSplit(name: "split1")],
archivedSplits: [],
changeNumber: 100,
updateTimestamp: 1000
)

splitsStorage.update(splitChange: change, onFailure: nil)

XCTAssertEqual(1, splitDao.insertedSplits.count)
XCTAssertEqual(100, generalInfoDao.longValue(info: .splitsChangeNumber))
}

func testSplitsAndGeneralInfoAreUpdatedTogether() {
let activeSplits = [createSplit(name: "s1"), createSplit(name: "s2")]
let archivedSplits = [createSplit(name: "s3")]
let change = ProcessedSplitChange(
activeSplits: activeSplits,
archivedSplits: archivedSplits,
changeNumber: 200,
updateTimestamp: 2000
)

splitsStorage.update(splitChange: change, onFailure: nil)

XCTAssertEqual(2, splitDao.insertedSplits.count, "Active splits should be inserted")
XCTAssertEqual(1, splitDao.deletedSplits?.count, "Archived splits should be deleted")
XCTAssertEqual(200, generalInfoDao.longValue(info: .splitsChangeNumber), "ChangeNumber should be updated")
XCTAssertEqual(2000, generalInfoDao.longValue(info: .splitsUpdateTimestamp), "UpdateTimestamp should be updated")
}

private func createSplit(name: String, trafficType: String = "user") -> Split {
let split = SplitTestHelper.newSplit(name: name, trafficType: trafficType)
split.status = .active
return split
}
}

Loading
Loading