diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index efd81ce4..249fec43 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -133,6 +133,7 @@ 473A986D2C91E032005A3B94 /* MonitoringLogsError.json in Resources */ = {isa = PBXBuildFile; fileRef = 473A986C2C91E032005A3B94 /* MonitoringLogsError.json */; }; 473A986F2C91E047005A3B94 /* MonitoringLogsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = 473A986E2C91E047005A3B94 /* MonitoringLogsTypeError.json */; }; 473A98712C91E629005A3B94 /* MonitoringLogsElementsMixedError.json in Resources */ = {isa = PBXBuildFile; fileRef = 473A98702C91E629005A3B94 /* MonitoringLogsElementsMixedError.json */; }; + 474230DC2E72236500282764 /* TestContainers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474230DB2E72236500282764 /* TestContainers.swift */; }; 4747708B2C6B838B00C36FC8 /* SharedInternalMethodsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4747708A2C6B838B00C36FC8 /* SharedInternalMethodsTests.swift */; }; 4747708F2C6B93AC00C36FC8 /* MindboxNotificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4747708E2C6B93AC00C36FC8 /* MindboxNotificationServiceTests.swift */; }; 474770912C6B9A7200C36FC8 /* MindboxNotificationContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474770902C6B9A7200C36FC8 /* MindboxNotificationContentTests.swift */; }; @@ -161,7 +162,13 @@ 4766A8BC2C9332ED002D15A4 /* ConfigABTestsOneElementError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4766A8BB2C9332ED002D15A4 /* ConfigABTestsOneElementError.json */; }; 4766A8BE2C933416002D15A4 /* ConfigABTestsOneElementTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4766A8BD2C933416002D15A4 /* ConfigABTestsOneElementTypeError.json */; }; 47980DB12E3B96EF0020EB34 /* CheckNotificationsStatusOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47980DB02E3B96EF0020EB34 /* CheckNotificationsStatusOperation.swift */; }; - 47A220A52E2158A00001507C /* EphermalSQLiteForMBLoggerCoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A220A42E2158A00001507C /* EphermalSQLiteForMBLoggerCoreDataManager.swift */; }; + 47A220A52E2158A00001507C /* IsolatedMBLoggerCoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A220A42E2158A00001507C /* IsolatedMBLoggerCoreDataManager.swift */; }; + 47A4FA6E2E7335A200569870 /* LoggerDatabaseLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A4FA6D2E7335A200569870 /* LoggerDatabaseLoader.swift */; }; + 47A4FA702E73421200569870 /* LoggerDBConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A4FA6F2E73421200569870 /* LoggerDBConfig.swift */; }; + 47A4FA722E73565400569870 /* LogStoreTrimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A4FA712E73565400569870 /* LogStoreTrimmer.swift */; }; + 47A4FA742E73569F00569870 /* SQLiteLogicalSizeMeasurer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A4FA732E73569F00569870 /* SQLiteLogicalSizeMeasurer.swift */; }; + 47A4FA762E735C5200569870 /* LogStoreTrimmerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A4FA752E735C5200569870 /* LogStoreTrimmerTests.swift */; }; + 47A4FA782E73741700569870 /* LoggerDatabaseLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47A4FA772E73741700569870 /* LoggerDatabaseLoaderTests.swift */; }; 47B90E2F2C625F9A00BD93E7 /* TestBaseMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47B90E2E2C625F9A00BD93E7 /* TestBaseMigrations.swift */; }; 47B90E312C626B9300BD93E7 /* TestProtocolMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47B90E302C626B9300BD93E7 /* TestProtocolMigrations.swift */; }; 47BD5BFB2C578BC600F965C0 /* MigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47BD5BFA2C578BC600F965C0 /* MigrationManager.swift */; }; @@ -754,6 +761,7 @@ 473A986C2C91E032005A3B94 /* MonitoringLogsError.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = MonitoringLogsError.json; sourceTree = ""; }; 473A986E2C91E047005A3B94 /* MonitoringLogsTypeError.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = MonitoringLogsTypeError.json; sourceTree = ""; }; 473A98702C91E629005A3B94 /* MonitoringLogsElementsMixedError.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = MonitoringLogsElementsMixedError.json; sourceTree = ""; }; + 474230DB2E72236500282764 /* TestContainers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContainers.swift; sourceTree = ""; }; 4747708A2C6B838B00C36FC8 /* SharedInternalMethodsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedInternalMethodsTests.swift; sourceTree = ""; }; 4747708E2C6B93AC00C36FC8 /* MindboxNotificationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxNotificationServiceTests.swift; sourceTree = ""; }; 474770902C6B9A7200C36FC8 /* MindboxNotificationContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxNotificationContentTests.swift; sourceTree = ""; }; @@ -782,7 +790,13 @@ 4766A8BB2C9332ED002D15A4 /* ConfigABTestsOneElementError.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = ConfigABTestsOneElementError.json; sourceTree = ""; }; 4766A8BD2C933416002D15A4 /* ConfigABTestsOneElementTypeError.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = ConfigABTestsOneElementTypeError.json; sourceTree = ""; }; 47980DB02E3B96EF0020EB34 /* CheckNotificationsStatusOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckNotificationsStatusOperation.swift; sourceTree = ""; }; - 47A220A42E2158A00001507C /* EphermalSQLiteForMBLoggerCoreDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EphermalSQLiteForMBLoggerCoreDataManager.swift; sourceTree = ""; }; + 47A220A42E2158A00001507C /* IsolatedMBLoggerCoreDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsolatedMBLoggerCoreDataManager.swift; sourceTree = ""; }; + 47A4FA6D2E7335A200569870 /* LoggerDatabaseLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerDatabaseLoader.swift; sourceTree = ""; }; + 47A4FA6F2E73421200569870 /* LoggerDBConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerDBConfig.swift; sourceTree = ""; }; + 47A4FA712E73565400569870 /* LogStoreTrimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogStoreTrimmer.swift; sourceTree = ""; }; + 47A4FA732E73569F00569870 /* SQLiteLogicalSizeMeasurer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLiteLogicalSizeMeasurer.swift; sourceTree = ""; }; + 47A4FA752E735C5200569870 /* LogStoreTrimmerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogStoreTrimmerTests.swift; sourceTree = ""; }; + 47A4FA772E73741700569870 /* LoggerDatabaseLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerDatabaseLoaderTests.swift; sourceTree = ""; }; 47B90E2E2C625F9A00BD93E7 /* TestBaseMigrations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestBaseMigrations.swift; sourceTree = ""; }; 47B90E302C626B9300BD93E7 /* TestProtocolMigrations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestProtocolMigrations.swift; sourceTree = ""; }; 47BD5BFA2C578BC600F965C0 /* MigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationManager.swift; sourceTree = ""; }; @@ -1272,6 +1286,7 @@ 3333C1A42681D3CF00B60D84 /* MindboxNotificationsTests */, 9BC24E6C28F694CA00C2619C /* SDKVersionProvider */, A17853BF29AF7E940072578F /* MindboxLogger */, + 47D395612E72186600C44CFE /* Frameworks */, 313B233125ADEA0F00A1CB72 /* Products */, ); sourceTree = ""; @@ -1801,6 +1816,13 @@ path = MigrationAbstractions; sourceTree = ""; }; + 47D395612E72186600C44CFE /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; 8400429F2614CDED00CA17C5 /* ClickNotificationManager */ = { isa = PBXGroup; children = ( @@ -2282,6 +2304,8 @@ A154E332299E10F400F8F074 /* Mocks */, A154E303299C189300F8F074 /* MBLoggerCoreDataManagerTests.swift */, A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */, + 47A4FA772E73741700569870 /* LoggerDatabaseLoaderTests.swift */, + 47A4FA752E735C5200569870 /* LogStoreTrimmerTests.swift */, ); path = MindboxLogger; sourceTree = ""; @@ -2290,7 +2314,8 @@ isa = PBXGroup; children = ( A154E333299E110E00F8F074 /* EventRepositoryMock.swift */, - 47A220A42E2158A00001507C /* EphermalSQLiteForMBLoggerCoreDataManager.swift */, + 47A220A42E2158A00001507C /* IsolatedMBLoggerCoreDataManager.swift */, + 474230DB2E72236500282764 /* TestContainers.swift */, ); path = Mocks; sourceTree = ""; @@ -2350,8 +2375,12 @@ children = ( A154E34D299E5B6D00F8F074 /* CDLogMessage.xcdatamodeld */, A154E34F299E5B6D00F8F074 /* MBLoggerCoreDataManager.swift */, + 47A4FA6F2E73421200569870 /* LoggerDBConfig.swift */, A154E350299E5B6D00F8F074 /* MBPersistentContainer.swift */, A154E351299E5B6D00F8F074 /* CDLogMessage+CoreDataClass.swift */, + 47A4FA6D2E7335A200569870 /* LoggerDatabaseLoader.swift */, + 47A4FA712E73565400569870 /* LogStoreTrimmer.swift */, + 47A4FA732E73569F00569870 /* SQLiteLogicalSizeMeasurer.swift */, ); path = LoggerRepository; sourceTree = ""; @@ -3971,6 +4000,7 @@ F32B68882CF83B030088BCDD /* InappConfigurationTests.swift in Sources */, 84FCD3B925CA109E00D1E574 /* MockNetworkFetcher.swift in Sources */, 9B9C9538292111A700BB29DA /* MockUUIDDebugService.swift in Sources */, + 47A4FA782E73741700569870 /* LoggerDatabaseLoaderTests.swift in Sources */, 84B625F025C98B1200AB6228 /* ValidatorsTestCase.swift in Sources */, 847F580325C88BBF00147A9A /* HTTPMethod.swift in Sources */, F351F1C22CE5F23A0053423E /* InappMapperTests.swift in Sources */, @@ -3980,6 +4010,7 @@ A17958992978E04600609E91 /* InAppStub.swift in Sources */, F39B67A72A3C6C6A005C0CCA /* SegmentationServiceTests.swift in Sources */, 476689C72C85B6AE0066BB12 /* ShownInAppsIdsMigrationTests.swift in Sources */, + 474230DC2E72236500282764 /* TestContainers.swift in Sources */, F3D925AB2A120C0F00135C87 /* InAppImageDownloaderMock.swift in Sources */, F3E3EEA62CFA27EC00AAC91A /* Tag+Extensions.swift in Sources */, F367301D2B7B8B6A00DD0039 /* NotificationFormatTests.swift in Sources */, @@ -4020,13 +4051,14 @@ A17958972978D2B300609E91 /* InAppTargetingCheckerTests.swift in Sources */, F39B67AE2A3C737F005C0CCA /* ImageDownloadServiceTests.swift in Sources */, 47B90E2F2C625F9A00BD93E7 /* TestBaseMigrations.swift in Sources */, + 47A4FA762E735C5200569870 /* LogStoreTrimmerTests.swift in Sources */, 84A0CD5A260B021F004CD91B /* MockFailureNetworkFetcher.swift in Sources */, 475558C32C59300400CDA026 /* MigrationManagerTests.swift in Sources */, 84E1D6A9261D8D54002BF03A /* DatabaseLoaderTest.swift in Sources */, 840C38B825D13A7D00D50183 /* EventGenerator.swift in Sources */, 84CC79A525CAE14200C062BD /* EventRepositoryTestCase.swift in Sources */, F367301B2B7B6E7600DD0039 /* MindboxPushValidatorTests.swift in Sources */, - 47A220A52E2158A00001507C /* EphermalSQLiteForMBLoggerCoreDataManager.swift in Sources */, + 47A220A52E2158A00001507C /* IsolatedMBLoggerCoreDataManager.swift in Sources */, A154E304299C189300F8F074 /* MBLoggerCoreDataManagerTests.swift in Sources */, F32E537D2C3FDB6D002C7CA0 /* DITestModuleReplaceableTests.swift in Sources */, 840C388525CD169400D50183 /* DatabaseRepositoryTestCase.swift in Sources */, @@ -4080,6 +4112,7 @@ buildActionMask = 2147483647; files = ( A17853E629AF7EE90072578F /* MBLogger.swift in Sources */, + 47A4FA6E2E7335A200569870 /* LoggerDatabaseLoader.swift in Sources */, A17853DC29AF7EDE0072578F /* String+Extensions.swift in Sources */, F3B2A3A02A4C79E200E2CA25 /* ValidationError.swift in Sources */, A17853E029AF7EDE0072578F /* URL+Extensions.swift in Sources */, @@ -4088,10 +4121,13 @@ A17853EC29AF7EF30072578F /* CDLogMessage+CoreDataClass.swift in Sources */, F397EEBE2A4489CE00D48CEC /* LoggerErrorModel.swift in Sources */, A17853D429AF7EBE0072578F /* SDKLogsStatus.swift in Sources */, + 47A4FA702E73421200569870 /* LoggerDBConfig.swift in Sources */, A17853DF29AF7EDE0072578F /* MBLoggerUtilitiesFetcher.swift in Sources */, F3B2A3982A4C79D900E2CA25 /* MindboxLogger.swift in Sources */, + 47A4FA722E73565400569870 /* LogStoreTrimmer.swift in Sources */, A17853DE29AF7EDE0072578F /* FileManager+Extensions.swift in Sources */, A17853DA29AF7ED90072578F /* LogLevel.swift in Sources */, + 47A4FA742E73569F00569870 /* SQLiteLogicalSizeMeasurer.swift in Sources */, F3B2A39F2A4C79E200E2CA25 /* UnknownDecodable.swift in Sources */, A17853EB29AF7EF30072578F /* MBPersistentContainer.swift in Sources */, A17853D829AF7ED90072578F /* LogWriter.swift in Sources */, diff --git a/Mindbox/Mindbox.swift b/Mindbox/Mindbox.swift index 0ca7d7cf..0070e82b 100644 --- a/Mindbox/Mindbox.swift +++ b/Mindbox/Mindbox.swift @@ -547,7 +547,6 @@ public class Mindbox: NSObject { override private init() { super.init() self.assembly() - MBLoggerCoreDataManager.shared.setUpAppLifeCycleObservers() } func assembly() { diff --git a/MindboxLogger/Shared/LoggerRepository/LogStoreTrimmer.swift b/MindboxLogger/Shared/LoggerRepository/LogStoreTrimmer.swift new file mode 100644 index 00000000..e5e69b8a --- /dev/null +++ b/MindboxLogger/Shared/LoggerRepository/LogStoreTrimmer.swift @@ -0,0 +1,92 @@ +// +// LogStoreTrimmer.swift +// MindboxLogger +// +// Created by Sergei Semko on 9/11/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import Foundation + +protocol Clock { + var now: Date { get } +} + +struct SystemClock: Clock { + public init() {} + public var now: Date { Date() } +} + +protocol LogStoreTrimming { + /// Attempts to perform a trim operation if the policy allows it. + /// + /// - Parameters: + /// - precomputedSizeKB: Optional precomputed database size in kilobytes. + /// If provided, the trimmer will **not** call the measurer. + /// - delete: A callback that must perform deletion given the computed fraction + /// (e.g. delete the oldest `fraction * N` items). May throw. + /// - Returns: `true` if a trim was performed; `false` if skipped (below limit or under cooldown). + /// - Throws: Rethrows any error thrown by `delete`. + @discardableResult + func maybeTrim(precomputedSizeKB: Int?, delete: (Double) throws -> Void) throws -> Bool + + /// Computes the fraction of items to delete in order to reach the configured + /// low-water mark. + /// + /// The result is clamped to `[minDeleteFraction, maxDeleteFraction]`. + /// Returns `nil` if the current size does not exceed the limit. + func computeTrimFraction(sizeKB: Int, limitKB: Int) -> Double? + + /// Resets cooldown so that the next `maybeTrim` call may run immediately. + func resetCooldown() +} + +final class LogStoreTrimmer: LogStoreTrimming { + private let config: LoggerDBConfig + private let sizeMeasurer: DatabaseSizeMeasuring + private let clock: Clock + + private var cooldownUntil: Date? + + init(config: LoggerDBConfig, + sizeMeasurer: DatabaseSizeMeasuring, + clock: Clock) { + self.config = config + self.sizeMeasurer = sizeMeasurer + self.clock = clock + } + + convenience init(config: LoggerDBConfig, + sizeMeasurer: DatabaseSizeMeasuring) { + self.init(config: config, sizeMeasurer: sizeMeasurer, clock: SystemClock()) + } + + func resetCooldown() { cooldownUntil = nil } + + func computeTrimFraction(sizeKB: Int, limitKB: Int) -> Double? { + guard sizeKB > limitKB else { return nil } + let targetKB = Int(Double(limitKB) * config.lowWaterRatio) + let raw = Double(sizeKB - targetKB) / Double(max(sizeKB, 1)) + let fraction = min(config.maxDeleteFraction, max(config.minDeleteFraction, raw)) + return fraction + } + + @discardableResult + func maybeTrim(precomputedSizeKB: Int? = nil, + delete: (Double) throws -> Void) rethrows -> Bool { + if let t = cooldownUntil, t > clock.now { return false } + let sizeKB = precomputedSizeKB ?? sizeMeasurer.sizeKB() + guard let fraction = computeTrimFraction(sizeKB: sizeKB, limitKB: config.dbSizeLimitKB) else { return false } + try delete(fraction) + cooldownUntil = clock.now.addingTimeInterval(config.trimCooldownSec) + return true + } +} + +#if DEBUG +final class ManualClock: Clock { + var now: Date + init(_ now: Date) { self.now = now } + func advance(_ seconds: TimeInterval) { now = now.addingTimeInterval(seconds) } +} +#endif diff --git a/MindboxLogger/Shared/LoggerRepository/LoggerDBConfig.swift b/MindboxLogger/Shared/LoggerRepository/LoggerDBConfig.swift new file mode 100644 index 00000000..0ea31719 --- /dev/null +++ b/MindboxLogger/Shared/LoggerRepository/LoggerDBConfig.swift @@ -0,0 +1,29 @@ +// +// LoggerDBConfig.swift +// MindboxLogger +// +// Created by Sergei Semko on 9/11/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import Foundation + +public struct LoggerDBConfig { + public let dbSizeLimitKB: Int + public let lowWaterRatio: Double + public let minDeleteFraction: Double + public let maxDeleteFraction: Double + public let batchSize: Int + public let writesPerTrimCheck: Int + public let trimCooldownSec: TimeInterval + + public static let `default` = LoggerDBConfig( + dbSizeLimitKB: 10_240, + lowWaterRatio: 0.85, + minDeleteFraction: 0.05, + maxDeleteFraction: 0.50, + batchSize: 15, + writesPerTrimCheck: 5, + trimCooldownSec: 10 + ) +} diff --git a/MindboxLogger/Shared/LoggerRepository/LoggerDatabaseLoader.swift b/MindboxLogger/Shared/LoggerRepository/LoggerDatabaseLoader.swift new file mode 100644 index 00000000..dc6b75a9 --- /dev/null +++ b/MindboxLogger/Shared/LoggerRepository/LoggerDatabaseLoader.swift @@ -0,0 +1,187 @@ +// +// LoggerDatabaseLoader.swift +// MindboxLogger +// +// Created by Sergei Semko on 9/11/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import Foundation +import CoreData + +protocol LoggerDatabaseLoading { + /// Loads a persistent container and returns it together with a background context. + /// + /// The loader is responsible for: + /// - Resolving the Core Data model (`.momd`) + /// - Resolving the SQLite store URL + /// - Applying store descriptions + /// - Attempting to load stores, and auto-recreating them on failure + func loadContainer() throws -> (container: MBPersistentContainer, context: NSManagedObjectContext) + + /// Destroys the persistent store if it exists (idempotent). + /// + /// Implementations should remove sidecar files (`-wal`, `-shm`) as well. + func destroyIfExists() throws +} + +struct LoggerDatabaseLoaderConfig { + /// Name of the Core Data model (and the SQLite file base name). + let modelName: String + /// Optional App Group identifier to place the store in shared container. + let applicationGroupId: String? + /// Explicit store URL. If set, it overrides other URL resolution logic. + let storeURL: URL? + /// Optional explicit store descriptions. If provided, they are used as-is. + let descriptions: [NSPersistentStoreDescription]? +} + +enum LoggerDatabaseLoaderError: LocalizedError { + /// The Core Data model (`.momd`) could not be found in any known bundle. + case modelNotFound(modelName: String) + + var errorDescription: String? { + switch self { + case .modelNotFound(let modelName): + return "Core Data model '\(modelName).momd' not found in any known bundle." + } + } +} + +final class LoggerDatabaseLoader: LoggerDatabaseLoading { + private let configuration: LoggerDatabaseLoaderConfig + + init(_ configuration: LoggerDatabaseLoaderConfig) { + self.configuration = configuration + } + + // MARK: - Public + + func loadContainer() throws -> (container: MBPersistentContainer, context: NSManagedObjectContext) { + MBPersistentContainer.applicationGroupIdentifier = configuration.applicationGroupId + + let managedObjectModel = try resolveModel() + + let container = MBPersistentContainer(name: configuration.modelName, managedObjectModel: managedObjectModel) + let storeURL = try resolveStoreURL() + + if let explicitDescriptions = configuration.descriptions, !explicitDescriptions.isEmpty { + container.persistentStoreDescriptions = explicitDescriptions + } else { + container.persistentStoreDescriptions = [defaultDescription(forStoreURL: storeURL)] + } + + do { + try loadStores(into: container) + } catch { + try destroyStore(at: storeURL, using: container) + try loadStores(into: container) + } + + let backgroundContext = container.newBackgroundContext() + backgroundContext.automaticallyMergesChangesFromParent = true + backgroundContext.mergePolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) + + return (container, backgroundContext) + } + + func destroyIfExists() throws { + let storeURL = try resolveStoreURL() + let fm = FileManager.default + + let walURL = URL(fileURLWithPath: storeURL.path + "-wal") + let shmURL = URL(fileURLWithPath: storeURL.path + "-shm") + + if fm.fileExists(atPath: storeURL.path) { + let psc = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel()) + if #available(iOS 15.0, *) { + // Force destroy + try? psc.destroyPersistentStore(at: storeURL, type: .sqlite, + options: [NSPersistentStoreForceDestroyOption: true]) + } else { + try? psc.destroyPersistentStore(at: storeURL, ofType: NSSQLiteStoreType, options: nil) + } + + if fm.fileExists(atPath: storeURL.path) { + try? fm.removeItem(at: storeURL) + } + } + + try? fm.removeItem(at: walURL) + try? fm.removeItem(at: shmURL) + } + + // MARK: - Model resolution + + private func resolveModel() throws -> NSManagedObjectModel { + #if SWIFT_PACKAGE + if let url = Bundle.module.url(forResource: configuration.modelName, withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: url) { + return model + } + #endif + + let ownerBundle = Bundle(for: MBLoggerCoreDataManager.self) + if let directURL = ownerBundle.url(forResource: configuration.modelName, withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: directURL) { + return model + } + + if let nestedURL = ownerBundle.url(forResource: "MindboxLogger", withExtension: "bundle"), + let nestedBundle = Bundle(url: nestedURL), + let modelURL = nestedBundle.url(forResource: configuration.modelName, withExtension: "momd"), + let model = NSManagedObjectModel(contentsOf: modelURL) { + return model + } + + throw LoggerDatabaseLoaderError.modelNotFound(modelName: configuration.modelName) + } + + // MARK: - Store helpers + + private func defaultDescription(forStoreURL storeURL: URL) -> NSPersistentStoreDescription { + let storeDescription = NSPersistentStoreDescription(url: storeURL) + storeDescription.setOption(FileProtectionType.none as NSObject, forKey: NSPersistentStoreFileProtectionKey) + storeDescription.shouldAddStoreAsynchronously = false + storeDescription.shouldMigrateStoreAutomatically = true + storeDescription.shouldInferMappingModelAutomatically = true + return storeDescription + } + + private func loadStores(into container: NSPersistentContainer) throws { + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error + } + if let loadError { + throw loadError + } + } + + private func destroyStore(at storeURL: URL, using container: NSPersistentContainer) throws { + if #available(iOS 15.0, *) { + try container.persistentStoreCoordinator.destroyPersistentStore(at: storeURL, type: .sqlite) + } else { + try container.persistentStoreCoordinator.destroyPersistentStore(at: storeURL, ofType: NSSQLiteStoreType) + } + } + + private func resolveStoreURL() throws -> URL { + if let firstDescription = configuration.descriptions?.first, let explicitURL = firstDescription.url { + return explicitURL + } + if let explicitURL = configuration.storeURL { + return explicitURL + } + if let applicationGroupId = configuration.applicationGroupId { + return FileManager.storeURL(for: applicationGroupId, databaseName: configuration.modelName) + } + let cachesDirectory = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + return cachesDirectory.appendingPathComponent("\(configuration.modelName).sqlite") + } +} diff --git a/MindboxLogger/Shared/LoggerRepository/MBLoggerCoreDataManager.swift b/MindboxLogger/Shared/LoggerRepository/MBLoggerCoreDataManager.swift index 7a5eeed9..f2cc08c4 100644 --- a/MindboxLogger/Shared/LoggerRepository/MBLoggerCoreDataManager.swift +++ b/MindboxLogger/Shared/LoggerRepository/MBLoggerCoreDataManager.swift @@ -8,26 +8,50 @@ import Foundation import CoreData -#if canImport(UIKit) +import os import UIKit.UIApplication -#endif public class MBLoggerCoreDataManager { - public static let shared = MBLoggerCoreDataManager() + public static let shared = MBLoggerCoreDataManager( + config: .default, + loader: LoggerDatabaseLoader(LoggerDatabaseLoaderConfig( + modelName: Constants.model, + applicationGroupId: MBLoggerUtilitiesFetcher().applicationGroupIdentifier, + storeURL: nil, + descriptions: nil + )) + ) + + enum StorageState { + case initializing + case enabled + case disabled + } + private(set) var storageState: StorageState + + private let osLog = OSLogWriter( + subsystem: Bundle.main.bundleIdentifier ?? "cloud.Mindbox.UndefinedHostApplication", + category: LogCategory.loggerDatabase.rawValue + ) + + private func log(_ level: LogLevel, _ msg: @autoclosure () -> String) { + guard level >= .error else { + return + } + osLog.writeMessage(msg(), logLevel: level) + } private enum Constants { - - enum UserDefaultsKeys { - static let previousDBSizeKey = "MBLoggerPersistenceStorage-previousDatabaseSize" - } - static let model = "CDLogMessage" - static let dbSizeLimitKB: Int = 10_240 - static let batchSize = 15 - static let limitTheNumberOfOperationsBeforeCheckingIfDeletionIsRequired = 5 + static let backgroundTaskName = "MB-BackgroundTask-writeLogsToCoreData-whenApplicationDidEnterBackground" + static let loggerQueueLabel = "com.Mindbox.loggerManager" } + private let loader: LoggerDatabaseLoading + private var config: LoggerDBConfig + private var trimmer: LogStoreTrimming? + private var logBuffer: [LogMessage] private let queue: DispatchQueue private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid @@ -35,65 +59,70 @@ public class MBLoggerCoreDataManager { private var writeCount = 0 { didSet { - if writeCount >= Constants.limitTheNumberOfOperationsBeforeCheckingIfDeletionIsRequired && !writesImmediately { + if writeCount >= self.config.writesPerTrimCheck && !writesImmediately { writeCount = 0 - checkDatabaseSizeAndDeleteIfNeeded() + queue.async { [weak self] in + self?.trimIfNeeded() + } } } } // MARK: CoreData objects - private lazy var persistentContainer: MBPersistentContainer = { - MBPersistentContainer.applicationGroupIdentifier = MBLoggerUtilitiesFetcher().applicationGroupIdentifier - - #if SWIFT_PACKAGE - guard let bundleURL = Bundle.module.url(forResource: Constants.model, withExtension: "momd"), - let mom = NSManagedObjectModel(contentsOf: bundleURL) else { - fatalError("Failed to initialize NSManagedObjectModel for \(Constants.model)") - } - let container = MBPersistentContainer(name: Constants.model, managedObjectModel: mom) - #else - let podBundle = Bundle(for: MBLoggerCoreDataManager.self) - let container: MBPersistentContainer - if let url = podBundle.url(forResource: "MindboxLogger", withExtension: "bundle"), - let bundle = Bundle(url: url), - let modelURL = bundle.url(forResource: Constants.model, withExtension: "momd"), - let mom = NSManagedObjectModel(contentsOf: modelURL) { - container = MBPersistentContainer(name: Constants.model, managedObjectModel: mom) - } else { - container = MBPersistentContainer(name: Constants.model) - } - #endif - - let storeURL = FileManager.storeURL(for: MBLoggerUtilitiesFetcher().applicationGroupIdentifier, databaseName: Constants.model) + private var persistentContainer: MBPersistentContainer? + private var context: NSManagedObjectContext? + + // MARK: Initializer + + private init(config: LoggerDBConfig, loader: LoggerDatabaseLoading) { + self.config = config + self.loader = loader - let storeDescription = NSPersistentStoreDescription(url: storeURL) - storeDescription.setOption(FileProtectionType.none as NSObject, forKey: NSPersistentStoreFileProtectionKey) - container.persistentStoreDescriptions = [storeDescription] - container.loadPersistentStores { _, error in - if let error = error { - fatalError("Failed to load persistent stores: \(error)") - } - } + self.logBuffer = [] + self.queue = DispatchQueue(label: Constants.loggerQueueLabel, qos: .utility) + self.storageState = .initializing - return container - }() + queue.async { [weak self] in self?.bootstrap() } + } - private lazy var context: NSManagedObjectContext = { - let context = persistentContainer.newBackgroundContext() - context.automaticallyMergesChangesFromParent = true - context.mergePolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) - return context - }() + deinit { + NotificationCenter.default.removeObserver(self) + } - // MARK: Initializer + private func bootstrap() { + do { + let loaded = try loader.loadContainer() + self.persistentContainer = loaded.container + self.context = loaded.context + + self.trimmer = LogStoreTrimmer( + config: config, + sizeMeasurer: SQLiteLogicalSizeMeasurer { [weak self] in + self?.context?.persistentStoreCoordinator?.persistentStores.first?.url + } + ) + + self.logBuffer.reserveCapacity(config.batchSize) + self.storageState = .enabled + + self.setupNotificationCenterObservers() + self.checkDatabaseSizeAndDeleteIfNeededThroughInit() + } catch { + self.context = nil + self.persistentContainer = nil + self.storageState = .disabled + log(.error, "[MBLoggerCDManager] bootstrap failed: \(error.localizedDescription)") + } + } - private init() { - self.logBuffer = [] - self.logBuffer.reserveCapacity(Constants.batchSize) - self.queue = DispatchQueue(label: "com.Mindbox.loggerManager", qos: .utility) - checkDatabaseSizeAndDeleteIfNeededThroughInit() + private var hasPersistentStore: Bool { + guard let psc = persistentContainer?.persistentStoreCoordinator else { return false } + return !psc.persistentStores.isEmpty + } + + private var isStoreLoaded: Bool { + storageState == .enabled && hasPersistentStore && context != nil } } @@ -106,13 +135,15 @@ public extension MBLoggerCoreDataManager { func create(message: String, timestamp: Date, completion: (() -> Void)? = nil) { queue.async { [weak self] in guard let self else { return } + guard self.isStoreLoaded else { completion?(); return } + let newLogMessage = LogMessage(timestamp: timestamp, message: message) if self.writesImmediately { self.persist([newLogMessage]) } else { self.logBuffer.append(newLogMessage) - if self.logBuffer.count >= Constants.batchSize { + if self.logBuffer.count >= self.config.batchSize { self.writeBufferToCoreData() } } @@ -127,6 +158,7 @@ public extension MBLoggerCoreDataManager { } private func persist(_ logs: [LogMessage]) { + guard storageState == .enabled, hasPersistentStore, let context else { return } guard !logs.isEmpty else { return } context.executePerformAndWait { @@ -137,12 +169,7 @@ public extension MBLoggerCoreDataManager { do { try context.execute(batchInsertRequest) } catch { - let errorMessage = "Failed to batch insert logs: \(error.localizedDescription)" - let errorLogData = [["message": errorMessage, "timestamp": Date()]] - let errorLogInsertRequest = NSBatchInsertRequest(entityName: Constants.model, objects: errorLogData) - do { - try context.execute(errorLogInsertRequest) - } catch { } + log(.error, "[MBLoggerCDManager] batch insert failed: \(error.localizedDescription)") } } else { // iOS 12 fallback @@ -151,7 +178,11 @@ public extension MBLoggerCoreDataManager { entity.message = log.message entity.timestamp = log.timestamp } - try? saveContext(context) + do { + try saveContext(context) + } catch { + log(.error, "[MBLoggerCDManager] save failed (iOS12 path): \(error.localizedDescription)") + } } writeCount += 1 @@ -169,6 +200,8 @@ public extension MBLoggerCoreDataManager { } private func fetchSingleLog(ascending: Bool) throws -> LogMessage? { + guard storageState == .enabled, hasPersistentStore, let context else { return nil } + var fetchedLogMessage: LogMessage? try context.executePerformAndWait { let fetchRequest = NSFetchRequest(entityName: Constants.model) @@ -182,6 +215,8 @@ public extension MBLoggerCoreDataManager { } func fetchPeriod(_ from: Date, _ to: Date, ascending: Bool = true) throws -> [LogMessage] { + guard storageState == .enabled, hasPersistentStore, let context else { return [] } + var fetchedLogs: [LogMessage] = [] try context.executePerformAndWait { @@ -208,26 +243,33 @@ public extension MBLoggerCoreDataManager { // MARK: Delete - func deleteTenPercentOfAllOldRecords() throws { + func deleteOldestLogs(fraction: Double) throws { + guard storageState == .enabled, hasPersistentStore, let context else { return } + try context.executePerformAndWait { let fetchRequest = NSFetchRequest(entityName: Constants.model) - let count = try context.count(for: fetchRequest) - let limit = Int((Double(count) * 0.1).rounded()) // 10% percent of all records should be removed - - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)] - fetchRequest.fetchLimit = limit - - let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) - deleteRequest.resultType = .resultTypeObjectIDs - - let batchDeleteResult = try context.execute(deleteRequest) as? NSBatchDeleteResult - if let objectIDs = batchDeleteResult?.result as? [NSManagedObjectID] { - let changes = [NSDeletedObjectsKey: objectIDs] - NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [context]) + guard count > 0 else { return } + + let toDelete = min(count, max(1, Int((Double(count) * fraction).rounded(.toNearestOrAwayFromZero)))) + + let idsReq = NSFetchRequest(entityName: Constants.model) + idsReq.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)] + idsReq.fetchLimit = toDelete + idsReq.resultType = .managedObjectIDResultType + + let del = NSBatchDeleteRequest(fetchRequest: idsReq) + del.resultType = .resultTypeObjectIDs + + if let res = try context.execute(del) as? NSBatchDeleteResult, + let ids = res.result as? [NSManagedObjectID] { + NSManagedObjectContext.mergeChanges( + fromRemoteContextSave: [NSDeletedObjectsKey: ids], + into: [context] + ) } - Logger.common(message: "[LoggerCDManager] Out of \(count) logs, \(limit) were deleted", level: .info, category: .loggerDatabase) + Logger.common(message: "[MBLoggerCDManager] Out of \(count) logs, \(toDelete) were deleted", level: .info, category: .loggerDatabase) } } } @@ -236,11 +278,6 @@ public extension MBLoggerCoreDataManager { public extension MBLoggerCoreDataManager { - @available(iOSApplicationExtension, unavailable) - func setUpAppLifeCycleObservers() { - setupNotificationCenterObservers() - } - func setImmediateWrite(_ enabled: Bool = true) { queue.async { self.writesImmediately = enabled @@ -252,6 +289,8 @@ public extension MBLoggerCoreDataManager { public extension MBLoggerCoreDataManager { func deleteAll() throws { + guard storageState == .enabled, hasPersistentStore, let context else { return } + self.logBuffer.removeAll(keepingCapacity: true) try context.executePerformAndWait { let fetchRequest = NSFetchRequest(entityName: Constants.model) @@ -270,7 +309,6 @@ public extension MBLoggerCoreDataManager { // MARK: - NotificationCenter observers setup -@available(iOSApplicationExtension, unavailable) private extension MBLoggerCoreDataManager { func setupNotificationCenterObservers() { NotificationCenter.default.addObserver(self, @@ -292,7 +330,7 @@ private extension MBLoggerCoreDataManager { @objc func applicationDidEnterBackground() { backgroundTaskID = UIApplication.shared.beginBackgroundTask( - withName: "MB-BackgroundTask-writeLogsToCoreData-whenApplicationDidEnterBackground" + withName: Constants.backgroundTaskName ) { [weak self] in guard let self else { return } UIApplication.shared.endBackgroundTask(self.backgroundTaskID) @@ -346,46 +384,23 @@ private extension MBLoggerCoreDataManager { // MARK: - Auxiliary private functions for checking the size of the database private extension MBLoggerCoreDataManager { - func checkDatabaseSizeAndDeleteIfNeededThroughInit() { - queue.async { [weak self] in - self?.checkDatabaseSizeAndDeleteIfNeeded() - } - } - func checkDatabaseSizeAndDeleteIfNeeded() { - let currentDBFileSize = getDBFileSize() - let previousDBSize = loadPreviousDBSize() - - let isDatabaseSizeChanged: Bool = currentDBFileSize != previousDBSize - - if currentDBFileSize > Constants.dbSizeLimitKB && isDatabaseSizeChanged { - do { - try deleteTenPercentOfAllOldRecords() - } catch { } + func checkDatabaseSizeAndDeleteIfNeededThroughInit() { + queue.asyncAfter(deadline: .now() + .seconds(5)) { [weak self] in + self?.trimIfNeeded() } - - savePreviousDBSize(currentDBFileSize) } - func getDBFileSize() -> Int { - guard let url = context.persistentStoreCoordinator?.persistentStores.first?.url else { - return 0 + func trimIfNeeded(precomputedSizeKB: Int? = nil) { + guard isStoreLoaded, let trimmer else { return } + do { + try trimmer.maybeTrim(precomputedSizeKB: precomputedSizeKB) { [weak self] fraction in + guard let self else { return } + try self.deleteOldestLogs(fraction: fraction) + } + } catch { + log(.error, "[MBLoggerCDManager] trim failed: \(error.localizedDescription)") } - let size = url.fileSize / 1024 // Bytes to Kilobytes - return Int(size) - } -} - -// MARK: - UserDefaults for previousDatabaseSize - -private extension MBLoggerCoreDataManager { - - func savePreviousDBSize(_ size: Int) { - UserDefaults.standard.set(size, forKey: Constants.UserDefaultsKeys.previousDBSizeKey) - } - - func loadPreviousDBSize() -> Int { - UserDefaults.standard.integer(forKey: Constants.UserDefaultsKeys.previousDBSizeKey) } } @@ -394,67 +409,59 @@ private extension MBLoggerCoreDataManager { #if DEBUG extension MBLoggerCoreDataManager { - // MARK: Properties - - var debugBatchSize: Int { - Constants.batchSize + convenience init(debug: Bool, config: LoggerDBConfig, loader: LoggerDatabaseLoading) { + self.init(config: config, loader: loader) } - var debugLimitTheNumberOfOperationsBeforeCheckingIfDeletionIsRequired: Int { - Constants.limitTheNumberOfOperationsBeforeCheckingIfDeletionIsRequired - } + // MARK: Introspection - var debugWritesImmediately: Bool { - self.writesImmediately + var debugBatchSize: Int { config.batchSize } + var debugLimitTheNumberOfOperationsBeforeCheckingIfDeletionIsRequired: Int { config.writesPerTrimCheck } + + var debugWritesImmediately: Bool { writesImmediately } + var debugWriteCount: Int { writeCount } + + var debugSerialQueue: DispatchQueue { queue } + var debugHasPersistentStore: Bool { hasPersistentStore } + var debugIsStoreLoaded: Bool { isStoreLoaded } + + var debugLogBufferCount: Int { logBuffer.count } + var debugLogBufferCapacity: Int { logBuffer.capacity } + + var debugContext: NSManagedObjectContext? { + get { context } + set { context = newValue } } - - var debugWriteCount: Int { - self.writeCount + + var debugStorageState: StorageState { + get { storageState } + set { storageState = newValue } } + + // MARK: Actions - var debugSerialQueue: DispatchQueue { - self.queue + func debugWriteBufferToCD() { + queue.async { self.writeBufferToCoreData() } } - - var debugPersistentContainer: MBPersistentContainer { - get { - self.persistentContainer - } - - set { - self.persistentContainer = newValue - } + + func debugFlushBufferInBackground() { + queue.async { self.flushBufferInBackground() } } - - var debugContext: NSManagedObjectContext { - get { - self.context - } - - set { - self.context = newValue - } + + func debugTrimIfNeeded(precomputedSizeKB: Int?) { + self.trimIfNeeded(precomputedSizeKB: precomputedSizeKB) } - - // MARK: Initializors - - convenience init(debug: Bool) { - self.init() + + func debugResetCooldown() { + queue.async { [weak self] in self?.trimmer?.resetCooldown() } } - - // MARK: Methods - - func debugWriteBufferToCD() { - queue.async { - self.writeBufferToCoreData() - } + + func debugResetWriteCount() { + queue.async { [weak self] in self?.writeCount = 0 } } - - @available(iOSApplicationExtension, unavailable) - func debugFlushBufferInBackground() { - queue.async { - self.flushBufferInBackground() - } + + func debugComputeTrimFraction(sizeKB: Int, limitKB: Int) -> Double? { + trimmer?.computeTrimFraction(sizeKB: sizeKB, limitKB: limitKB) } } #endif diff --git a/MindboxLogger/Shared/LoggerRepository/SQLiteLogicalSizeMeasurer.swift b/MindboxLogger/Shared/LoggerRepository/SQLiteLogicalSizeMeasurer.swift new file mode 100644 index 00000000..9345d161 --- /dev/null +++ b/MindboxLogger/Shared/LoggerRepository/SQLiteLogicalSizeMeasurer.swift @@ -0,0 +1,49 @@ +// +// SQLiteLogicalSizeMeasurer.swift +// MindboxLogger +// +// Created by Sergei Semko on 9/11/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import Foundation +import SQLite3 + +protocol DatabaseSizeMeasuring { + /// Returns the current logical database size in kilobytes. + /// + /// The exact definition of “logical” depends on the implementation. + /// For SQLite this usually means *used* pages (excluding freelist pages), + /// computed from `page_count - freelist_count`, multiplied by `page_size`. + func sizeKB() -> Int +} + +final class SQLiteLogicalSizeMeasurer: DatabaseSizeMeasuring { + private let urlProvider: () -> URL? + + init(urlProvider: @escaping () -> URL?) { + self.urlProvider = urlProvider + } + + func sizeKB() -> Int { + guard let url = urlProvider() else { return 0 } + var db: OpaquePointer? + guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, + let dbUnwrapped = db else { return 0 } + defer { sqlite3_close(dbUnwrapped) } + + func pragmaInt(_ name: String) -> Int64 { + var stmt: OpaquePointer? + defer { if stmt != nil { sqlite3_finalize(stmt) } } + guard sqlite3_prepare_v2(dbUnwrapped, "PRAGMA \(name);", -1, &stmt, nil) == SQLITE_OK, + sqlite3_step(stmt) == SQLITE_ROW else { return 0 } + return sqlite3_column_int64(stmt, 0) + } + + let pageSize = pragmaInt("page_size") + let pageCount = pragmaInt("page_count") + let freeList = pragmaInt("freelist_count") + let usedBytes = max(0, (pageCount - freeList)) * pageSize + return Int(usedBytes / 1024) + } +} diff --git a/MindboxTests/MindboxLogger/LogStoreTrimmerTests.swift b/MindboxTests/MindboxLogger/LogStoreTrimmerTests.swift new file mode 100644 index 00000000..5432d2fb --- /dev/null +++ b/MindboxTests/MindboxLogger/LogStoreTrimmerTests.swift @@ -0,0 +1,172 @@ +// +// LogStoreTrimmerTests.swift +// MindboxTests +// +// Created by Sergei Semko on 9/11/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import XCTest +@testable import MindboxLogger + +final class LogStoreTrimmerTests: XCTestCase { + + // MARK: - Fakes + + private final class StubMeasurer: DatabaseSizeMeasuring { + var size: Int + var calls: Int = 0 + init(size: Int) { self.size = size } + func sizeKB() -> Int { calls += 1; return size } + } + + private func makeConfig( + limit: Int = 128, + lowWater: Double = 0.85, + min: Double = 0.05, + max: Double = 0.50, + cooldown: TimeInterval = 10 + ) -> LoggerDBConfig { + LoggerDBConfig( + dbSizeLimitKB: limit, + lowWaterRatio: lowWater, + minDeleteFraction: min, + maxDeleteFraction: max, + batchSize: 15, + writesPerTrimCheck: 5, + trimCooldownSec: cooldown + ) + } + + private func makeTrimmer( + size: Int, + config: LoggerDBConfig? = nil, + start: Date = Date(timeIntervalSince1970: 0) + ) -> (LogStoreTrimmer, StubMeasurer, ManualClock) { + let cfg = config ?? makeConfig() + let measurer = StubMeasurer(size: size) + let clock = ManualClock(start) + let trimmer = LogStoreTrimmer(config: cfg, sizeMeasurer: measurer, clock: clock) + return (trimmer, measurer, clock) + } + + // MARK: - computeTrimFraction + + func testComputeTrimFraction_ReturnsNil_WhenBelowOrEqualLimit() { + let cfg = makeConfig(limit: 100) + let (trimmer, _, _) = makeTrimmer(size: 0, config: cfg) + XCTAssertNil(trimmer.computeTrimFraction(sizeKB: 100, limitKB: 100)) + XCTAssertNil(trimmer.computeTrimFraction(sizeKB: 99, limitKB: 100)) + } + + func testComputeTrimFraction_RespectsMin_WhenSlightlyOverLimit() { + // For min to work, you need: + // 1) to be SLIGHTLY above the limit (size > limit) so as not to get nil; + // 2) to have raw < min. raw = (size - target) / size; target = limit * lowWater. + // Let's take limit=100, lowWater=0.98 → target=98. + // When size=101: raw ≈ (101-98)/101 ≈ 0.0297 < min(0.05) → result = 0.05. + let cfg = makeConfig(limit: 100, lowWater: 0.98, min: 0.05, max: 0.5) + let (trimmer, _, _) = makeTrimmer(size: 0, config: cfg) + let f = trimmer.computeTrimFraction(sizeKB: 101, limitKB: 100) + XCTAssertNotNil(f) + XCTAssertEqual(f!, 0.05, accuracy: 1e-9) + } + + func testComputeTrimFraction_PassesThrough_WhenWithinMinMax() { + // limit=100, lowWater=0.8 → target=80, size=100 → equal to limit → nil + let cfg = makeConfig(limit: 100, lowWater: 0.8, min: 0.05, max: 0.5) + let (trimmer, _, _) = makeTrimmer(size: 0, config: cfg) + + let f = trimmer.computeTrimFraction(sizeKB: 100, limitKB: 100) + XCTAssertNil(f) + + // Slightly above the limit: raw ≈ (101-80)/101 ≈ 0.2079 → between min and max + let f2 = trimmer.computeTrimFraction(sizeKB: 101, limitKB: 100) + XCTAssertEqual(f2!, 0.2079, accuracy: 1e-3) + } + + func testComputeTrimFraction_CapsAtMax_WhenWayOverLimit() { + let cfg = makeConfig(limit: 100, lowWater: 0.8, min: 0.05, max: 0.5) + let (trimmer, _, _) = makeTrimmer(size: 0, config: cfg) + let f = trimmer.computeTrimFraction(sizeKB: 10_000, limitKB: 100) + XCTAssertEqual(f!, 0.5, accuracy: 1e-9) + } + + // MARK: - maybeTrim + + func testMaybeTrim_UsesPrecomputedSize_DoesNotCallMeasurer() { + let (trimmer, measurer, _) = makeTrimmer(size: 10_000) + var received: Double? + + _ = trimmer.maybeTrim(precomputedSizeKB: 129) { fraction in + received = fraction + } + XCTAssertEqual(measurer.calls, 0) // the meter was not used + XCTAssertNotNil(received) // the trim happened + } + + func testMaybeTrim_CallsDelete_WhenOverLimit_AndSetsCooldown() { + let cfg = makeConfig(limit: 100, lowWater: 0.8, min: 0.05, max: 0.5, cooldown: 10) + let (trimmer, measurer, clock) = makeTrimmer(size: 120, config: cfg, start: Date(timeIntervalSince1970: 0)) + + var calls = 0 + var fractions: [Double] = [] + + _ = trimmer.maybeTrim(precomputedSizeKB: nil) { f in + calls += 1 + fractions.append(f) + } + XCTAssertEqual(measurer.calls, 1) + XCTAssertEqual(calls, 1) + XCTAssertFalse(fractions.isEmpty) + + // Repeated call before the cooldown expires — should not trim + _ = trimmer.maybeTrim(precomputedSizeKB: nil) { _ in + XCTFail("Should not be called under cooldown") + } + XCTAssertEqual(calls, 1) + + // We advance the clock by 9 seconds — still cooldown. + clock.advance(9) + _ = trimmer.maybeTrim(precomputedSizeKB: nil) { _ in + XCTFail("Should not be called under cooldown") + } + XCTAssertEqual(calls, 1) + + // At the 10-second mark, the cooldown ended. + clock.advance(1) + _ = trimmer.maybeTrim(precomputedSizeKB: nil) { f in + calls += 1 + fractions.append(f) + } + XCTAssertEqual(calls, 2) + } + + func testMaybeTrim_RethrowsErrorFromDelete() { + let (trimmer, _, _) = makeTrimmer(size: 10_000) + enum E: Error { case boom } + + XCTAssertThrowsError(try trimmer.maybeTrim(precomputedSizeKB: nil) { _ in + throw E.boom + }) { error in + guard case E.boom = error else { return XCTFail("Wrong error") } + } + } + + func testResetCooldown_AllowsTrimAgainImmediately() { + let (trimmer, _, _) = makeTrimmer(size: 10_000) + + var count = 0 + _ = trimmer.maybeTrim { _ in count += 1 } + XCTAssertEqual(count, 1) + + // immediate repeat — should not work + _ = trimmer.maybeTrim { _ in count += 1 } + XCTAssertEqual(count, 1) + + // reset the cooldown — you can do it again + trimmer.resetCooldown() + _ = trimmer.maybeTrim { _ in count += 1 } + XCTAssertEqual(count, 2) + } +} diff --git a/MindboxTests/MindboxLogger/LoggerDatabaseLoaderTests.swift b/MindboxTests/MindboxLogger/LoggerDatabaseLoaderTests.swift new file mode 100644 index 00000000..f1417ffc --- /dev/null +++ b/MindboxTests/MindboxLogger/LoggerDatabaseLoaderTests.swift @@ -0,0 +1,153 @@ +// +// LoggerDatabaseLoaderTests.swift +// MindboxTests +// +// Created by Sergei Semko on 9/12/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import XCTest +import CoreData +@testable import MindboxLogger + +@available(iOS 15.0, *) +final class LoggerDatabaseLoaderTests: XCTestCase { + + // MARK: - Helpers + + private func tmpURL(_ name: String) -> URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("MB-Loader-\(name)-\(UUID().uuidString).sqlite") + } + + private func sqliteHeader(at url: URL) -> String? { + (try? Data(contentsOf: url).prefix(15)).flatMap { String(data: $0, encoding: .ascii) } + } + + func test_loadContainer_success_defaultDescription_createsStoreAndContext() throws { + let url = tmpURL("Success") + let cfg = LoggerDatabaseLoaderConfig( + modelName: "CDLogMessage", + applicationGroupId: nil, + storeURL: url, + descriptions: nil + ) + let loader = LoggerDatabaseLoader(cfg) + + let (container, ctx) = try loader.loadContainer() + + // The file has been created and is valid SQLite. + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + XCTAssertEqual(sqliteHeader(at: url), "SQLite format 3") + + // Background context working — trying the simplest recording + try ctx.performAndWait { + let entity = NSEntityDescription.entity(forEntityName: "CDLogMessage", in: ctx)! + let obj = NSManagedObject(entity: entity, insertInto: ctx) + obj.setValue("test", forKey: "message") + obj.setValue(Date(), forKey: "timestamp") + try ctx.save() + } + + // The container did indeed use defaultDescription(for: url) + XCTAssertEqual(container.persistentStoreDescriptions.first?.url, url) + XCTAssertEqual(container.persistentStoreDescriptions.count, 1) + XCTAssertFalse(container.persistentStoreDescriptions[0].shouldAddStoreAsynchronously) + } + + func test_loadContainer_autoRecreates_onCorruptedStore() throws { + let url = tmpURL("Corrupted") + + // We create a “broken” file and sidecars so that the first download fails. + try "NOT A SQLITE DB".data(using: .utf8)!.write(to: url, options: .atomic) + try "WAL".data(using: .utf8)!.write(to: URL(fileURLWithPath: url.path + "-wal")) + try "SHM".data(using: .utf8)!.write(to: URL(fileURLWithPath: url.path + "-shm")) + + let cfg = LoggerDatabaseLoaderConfig( + modelName: "CDLogMessage", + applicationGroupId: nil, + storeURL: url, + descriptions: nil + ) + let loader = LoggerDatabaseLoader(cfg) + + // The first loadStores attempt should fail → catch: destroyStore(...) → successful reload + let (_, ctx) = try loader.loadContainer() + + XCTAssertEqual(sqliteHeader(at: url), "SQLite format 3") + + // And you can write + try ctx.performAndWait { + let entity = NSEntityDescription.entity(forEntityName: "CDLogMessage", in: ctx)! + let obj = NSManagedObject(entity: entity, insertInto: ctx) + obj.setValue("after-recreate", forKey: "message") + obj.setValue(Date(), forKey: "timestamp") + try ctx.save() + } + } + + func test_loadContainer_usesExplicitDescriptionURL() throws { + let url = tmpURL("Explicit") + let desc = NSPersistentStoreDescription(url: url) + desc.type = NSSQLiteStoreType + desc.shouldAddStoreAsynchronously = false + + let cfg = LoggerDatabaseLoaderConfig( + modelName: "CDLogMessage", + applicationGroupId: nil, + storeURL: nil, + descriptions: [desc] + ) + let loader = LoggerDatabaseLoader(cfg) + + let (container, _) = try loader.loadContainer() + + XCTAssertEqual(container.persistentStoreDescriptions.count, 1) + XCTAssertEqual(container.persistentStoreDescriptions.first?.url, url) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } + + func test_loadContainer_throwsWhenModelNotFound() { + let cfg = LoggerDatabaseLoaderConfig( + modelName: "ModelThatDoesNotExist", + applicationGroupId: nil, + storeURL: nil, + descriptions: nil + ) + let loader = LoggerDatabaseLoader(cfg) + + XCTAssertThrowsError(try loader.loadContainer()) { error in + guard case LoggerDatabaseLoaderError.modelNotFound(let name) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(name, "ModelThatDoesNotExist") + } + } + + func test_destroyIfExists_removesStoreAndSidecars() throws { + let url = tmpURL("DestroyMe") + let cfg = LoggerDatabaseLoaderConfig( + modelName: "CDLogMessage", + applicationGroupId: nil, + storeURL: url, + descriptions: nil + ) + let loader = LoggerDatabaseLoader(cfg) + + // Create a store and immediately release all references (so that the file is not occupied by Core Data). + try autoreleasepool { + _ = try loader.loadContainer() + } + + let fm = FileManager.default + XCTAssertTrue(fm.fileExists(atPath: url.path)) + _ = fm.createFile(atPath: url.path + "-wal", contents: Data()) + _ = fm.createFile(atPath: url.path + "-shm", contents: Data()) + + try loader.destroyIfExists() + + XCTAssertFalse(fm.fileExists(atPath: url.path)) + XCTAssertFalse(fm.fileExists(atPath: url.path + "-wal")) + XCTAssertFalse(fm.fileExists(atPath: url.path + "-shm")) + } +} diff --git a/MindboxTests/MindboxLogger/MBLoggerCoreDataManagerTests.swift b/MindboxTests/MindboxLogger/MBLoggerCoreDataManagerTests.swift index 969cc3d1..9ebbbb0c 100644 --- a/MindboxTests/MindboxLogger/MBLoggerCoreDataManagerTests.swift +++ b/MindboxTests/MindboxLogger/MBLoggerCoreDataManagerTests.swift @@ -10,26 +10,20 @@ import XCTest @testable import MindboxLogger @testable import Mindbox - final class MBLoggerCoreDataManagerTests: XCTestCase { - private var batchSizeConstant = MBLoggerCoreDataManager.shared.debugBatchSize - + private var batchSizeConstant: Int! var manager: MBLoggerCoreDataManager! override func setUpWithError() throws { try super.setUpWithError() - manager = MBLoggerCoreDataManager.makeEphemeral() - manager.setUpAppLifeCycleObservers() - - manager.debugSerialQueue.async { - try? self.manager.deleteAll() - } - let fetchExpectation = XCTestExpectation(description: "Fetch delete all logs") - manager.debugSerialQueue.async { - fetchExpectation.fulfill() - } - wait(for: [fetchExpectation]) + manager = MBLoggerCoreDataManager.makeIsolated() + MBLoggerCoreDataManager.waitUntilReady(manager) + + try? manager.deleteAll() + MBLoggerCoreDataManager.drainQueue(manager) + + batchSizeConstant = manager.debugBatchSize } override func tearDown() { @@ -39,363 +33,396 @@ final class MBLoggerCoreDataManagerTests: XCTestCase { func test_measure_create_one_batch() throws { measure { - let fetchExpectation = XCTestExpectation(description: "Fetch created log") - fetchExpectation.expectedFulfillmentCount = batchSizeConstant - createMessages(range: 1...batchSizeConstant) { _, _ in - fetchExpectation.fulfill() - } - - wait(for: [fetchExpectation]) + let exp = expectation(description: "Fetch created log") + exp.expectedFulfillmentCount = batchSizeConstant + createMessages(range: 1...batchSizeConstant) { _, _ in exp.fulfill() } + wait(for: [exp]) } } func test_measure_create_1_000() throws { let logsCount = 1_000 measure { - let fetchExpectation = XCTestExpectation(description: "Fetch created log") - fetchExpectation.expectedFulfillmentCount = logsCount - - createMessages(range: 0.. MBLoggerCoreDataManager { - let manager = MBLoggerCoreDataManager(debug: true) - - manager.debugPersistentContainer = Self.testContainer - manager.debugContext = Self.testContainer.newBackgroundContext() - manager.debugContext.mergePolicy = NSMergePolicy( - merge: .mergeByPropertyStoreTrumpMergePolicyType - ) - return manager - } -} -#endif diff --git a/MindboxTests/MindboxLogger/Mocks/IsolatedMBLoggerCoreDataManager.swift b/MindboxTests/MindboxLogger/Mocks/IsolatedMBLoggerCoreDataManager.swift new file mode 100644 index 00000000..a0121086 --- /dev/null +++ b/MindboxTests/MindboxLogger/Mocks/IsolatedMBLoggerCoreDataManager.swift @@ -0,0 +1,80 @@ +// +// IsolatedMBLoggerCoreDataManager.swift +// MindboxTests +// +// Created by Sergei Semko on 7/11/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import Foundation +import CoreData +import XCTest +@testable import MindboxLogger + +#if DEBUG +extension LoggerDBConfig { + public static let tests = LoggerDBConfig( + dbSizeLimitKB: 128, + lowWaterRatio: 0.85, + minDeleteFraction: 0.05, + maxDeleteFraction: 0.50, + batchSize: 15, + writesPerTrimCheck: 5, + trimCooldownSec: 0 + ) +} + + +extension MBLoggerCoreDataManager { + static func makeIsolated( + config: LoggerDBConfig = .tests, + filePrefix: String = "MindboxLogger-Tests" + ) -> MBLoggerCoreDataManager { + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("\(filePrefix)-\(UUID().uuidString).sqlite") + + let description = NSPersistentStoreDescription(url: tempURL) + description.type = NSSQLiteStoreType + description.shouldAddStoreAsynchronously = false + description.setOption(FileProtectionType.none as NSObject, forKey: NSPersistentStoreFileProtectionKey) + description.shouldMigrateStoreAutomatically = true + description.shouldInferMappingModelAutomatically = true + + let loader = LoggerDatabaseLoader(.init( + modelName: "CDLogMessage", + applicationGroupId: nil, + storeURL: tempURL, + descriptions: [description] + )) + + let manager = MBLoggerCoreDataManager(debug: true, config: config, loader: loader) + waitUntilReady(manager) + drainQueue(manager) + manager.debugResetCooldown() + manager.debugResetWriteCount() + return manager + } + + /// Waiting for bootstrap to finish (storageState != .initializing) + @discardableResult + static func waitUntilReady(_ m: MBLoggerCoreDataManager, timeout: TimeInterval = 5) -> XCTWaiter.Result { + let exp = XCTestExpectation(description: "MBLogger ready/disabled") + func tick() { + if m.storageState != .initializing { exp.fulfill() } + else { DispatchQueue.global().asyncAfter(deadline: .now() + 0.05, execute: tick) } + } + tick() + return XCTWaiter.wait(for: [exp], timeout: timeout) + } + + /// Wait for the manager's serial queue to be cleared + @discardableResult + static func drainQueue(_ m: MBLoggerCoreDataManager, timeout: TimeInterval = 2) -> XCTWaiter.Result { + let exp = XCTestExpectation(description: "drain queue") + m.debugSerialQueue.async { exp.fulfill() } + return XCTWaiter.wait(for: [exp], timeout: timeout) + } +} +#endif + + diff --git a/MindboxTests/MindboxLogger/Mocks/TestContainers.swift b/MindboxTests/MindboxLogger/Mocks/TestContainers.swift new file mode 100644 index 00000000..de65eafd --- /dev/null +++ b/MindboxTests/MindboxLogger/Mocks/TestContainers.swift @@ -0,0 +1,60 @@ +// +// TestContainers.swift +// MindboxTests +// +// Created by Sergei Semko on 9/11/25. +// Copyright © 2025 Mindbox. All rights reserved. +// + +import Foundation +import CoreData +@testable import MindboxLogger + +final class AlwaysFailLoader: LoggerDatabaseLoading { + func loadContainer() throws -> (container: MBPersistentContainer, context: NSManagedObjectContext) { + struct E: Error {} + throw E() + } + func destroyIfExists() throws {} +} + +final class EphemeralSQLiteLoader: LoggerDatabaseLoading { + private let url: URL + private let modelName: String + + init(modelName: String = "CDLogMessage") { + self.modelName = modelName + self.url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("MB-Tests-\(UUID().uuidString).sqlite") + } + + func loadContainer() throws -> (container: MBPersistentContainer, context: NSManagedObjectContext) { + MBPersistentContainer.applicationGroupIdentifier = nil + + let ownerBundle = Bundle(for: MBLoggerCoreDataManager.self) + guard + let bundleURL = ownerBundle.url(forResource: "MindboxLogger", withExtension: "bundle"), + let bundle = Bundle(url: bundleURL), + let modelURL = bundle.url(forResource: modelName, withExtension: "momd"), + let mom = NSManagedObjectModel(contentsOf: modelURL) + else { throw LoggerDatabaseLoaderError.modelNotFound(modelName: modelName) } + + let container = MBPersistentContainer(name: modelName, managedObjectModel: mom) + let d = NSPersistentStoreDescription(url: url) + d.type = NSSQLiteStoreType + d.shouldAddStoreAsynchronously = false + d.setOption(FileProtectionType.none as NSObject, forKey: NSPersistentStoreFileProtectionKey) + container.persistentStoreDescriptions = [d] + + var err: Error? + container.loadPersistentStores { _, e in err = e } + if let err { throw err } + + let ctx = container.newBackgroundContext() + ctx.automaticallyMergesChangesFromParent = true + ctx.mergePolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) + return (container, ctx) + } + + func destroyIfExists() throws {} +}