From fdc754301c8c4bd497a132fdee868213e73e56b7 Mon Sep 17 00:00:00 2001 From: Nicky <31034418+nickybondarenko@users.noreply.github.com> Date: Mon, 15 Apr 2024 14:21:15 +0200 Subject: [PATCH] feat: add EventStorage (#87) * feat: add EventStorage Co-authored-by: Nicklas Lundin Co-authored-by: vahid torkaman --- .../ConfidenceDemoTests.swift | 10 -- .../ConfidenceDemoUITests.swift | 5 - .../Utils => Confidence}/Backport.swift | 12 +- Sources/Confidence/EventSenderEngine.swift | 8 +- Sources/Confidence/EventSenderStorage.swift | 8 -- Sources/Confidence/EventStorage.swift | 126 ++++++++++++++++++ .../Cache/DefaultStorage.swift | 2 +- Tests/ConfidenceTests/EventStorageTests.swift | 46 +++++++ 8 files changed, 180 insertions(+), 37 deletions(-) rename Sources/{ConfidenceProvider/Utils => Confidence}/Backport.swift (90%) create mode 100644 Sources/Confidence/EventStorage.swift create mode 100644 Tests/ConfidenceTests/EventStorageTests.swift diff --git a/ConfidenceDemoApp/ConfidenceDemoAppTests/ConfidenceDemoTests.swift b/ConfidenceDemoApp/ConfidenceDemoAppTests/ConfidenceDemoTests.swift index 9da9608e..3f155efe 100644 --- a/ConfidenceDemoApp/ConfidenceDemoAppTests/ConfidenceDemoTests.swift +++ b/ConfidenceDemoApp/ConfidenceDemoAppTests/ConfidenceDemoTests.swift @@ -2,16 +2,6 @@ import XCTest @testable import ConfidenceDemoApp final class ConfidenceDemoTests: XCTestCase { - override func setUpWithError() throws { - try super.setUpWithError() - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - try super.tearDownWithError() - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - func testExample() throws { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. diff --git a/ConfidenceDemoApp/ConfidenceDemoAppUITests/ConfidenceDemoUITests.swift b/ConfidenceDemoApp/ConfidenceDemoAppUITests/ConfidenceDemoUITests.swift index 1c1d8c7a..59ec3be2 100644 --- a/ConfidenceDemoApp/ConfidenceDemoAppUITests/ConfidenceDemoUITests.swift +++ b/ConfidenceDemoApp/ConfidenceDemoAppUITests/ConfidenceDemoUITests.swift @@ -12,11 +12,6 @@ final class ConfidenceDemoUITests: XCTestCase { // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } - override func tearDownWithError() throws { - try super.tearDownWithError() - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - func testExample() throws { // UI tests must launch the application that they test. let app = XCUIApplication() diff --git a/Sources/ConfidenceProvider/Utils/Backport.swift b/Sources/Confidence/Backport.swift similarity index 90% rename from Sources/ConfidenceProvider/Utils/Backport.swift rename to Sources/Confidence/Backport.swift index 28fa33e5..69fa6103 100644 --- a/Sources/ConfidenceProvider/Utils/Backport.swift +++ b/Sources/Confidence/Backport.swift @@ -1,10 +1,10 @@ import Foundation -extension URL { +public extension URL { struct Backport { var base: URL - init(base: URL) { + public init(base: URL) { self.base = base } } @@ -14,7 +14,7 @@ extension URL { } } -extension URL.Backport { +public extension URL.Backport { var path: String { if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { return self.base.path(percentEncoded: false) @@ -36,14 +36,14 @@ extension URL.Backport { } } -extension Date { +public extension Date { struct Backport { } static var backport: Backport.Type { Backport.self } } -extension Date.Backport { +public extension Date.Backport { static var now: Date { if #available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) { return Date.now @@ -60,7 +60,7 @@ extension Date.Backport { } } - static public func toISOString(date: Date) -> String { + static func toISOString(date: Date) -> String { if #available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) { return date.ISO8601Format() } else { diff --git a/Sources/Confidence/EventSenderEngine.swift b/Sources/Confidence/EventSenderEngine.swift index 3cebf628..8e975642 100644 --- a/Sources/Confidence/EventSenderEngine.swift +++ b/Sources/Confidence/EventSenderEngine.swift @@ -5,12 +5,6 @@ protocol EventsUploader { func upload(request: [Event]) async -> Bool } -struct Event: Encodable, Equatable { - let name: String - let payload: [String: ConfidenceValue] - let eventTime: Date -} - protocol FlushPolicy { func reset() func hit(event: Event) @@ -72,7 +66,7 @@ final class EventSenderEngineImpl: EventSenderEngine { do { guard let self = self else { return } try self.storage.startNewBatch() - let ids = storage.batchReadyIds() + let ids = try storage.batchReadyIds() for id in ids { let events = try self.storage.eventsFrom(id: id) let shouldCleanup = await self.uploader.upload(request: events) diff --git a/Sources/Confidence/EventSenderStorage.swift b/Sources/Confidence/EventSenderStorage.swift index d25333bf..e0b07357 100644 --- a/Sources/Confidence/EventSenderStorage.swift +++ b/Sources/Confidence/EventSenderStorage.swift @@ -5,11 +5,3 @@ struct EventBatchRequest: Encodable { let sendTime: Date let events: [Event] } - -internal protocol EventStorage { - func startNewBatch() throws - func writeEvent(event: Event) throws - func batchReadyIds() -> [String] - func eventsFrom(id: String) throws -> [Event] - func remove(id: String) throws -} diff --git a/Sources/Confidence/EventStorage.swift b/Sources/Confidence/EventStorage.swift new file mode 100644 index 00000000..02bf7efa --- /dev/null +++ b/Sources/Confidence/EventStorage.swift @@ -0,0 +1,126 @@ +import Foundation +import os + +internal protocol EventStorage { + func startNewBatch() throws + func writeEvent(event: Event) throws + func batchReadyIds() throws -> [String] + func eventsFrom(id: String) throws -> [Event] + func remove(id: String) throws +} + +internal class EventStorageImpl: EventStorage { + private let READYTOSENDEXTENSION = "READY" + private let storageQueue = DispatchQueue(label: "com.confidence.events.storage") + private var folderURL: URL + private var currentFileUrl: URL? + private var currentFileHandle: FileHandle? + + init() throws { + self.folderURL = try EventStorageImpl.getFolderURL() + if !FileManager.default.fileExists(atPath: folderURL.backport.path) { + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + } + try resetCurrentFile() + } + + func startNewBatch() throws { + try storageQueue.sync { + guard let currentFileName = self.currentFileUrl else { + return + } + try currentFileHandle?.close() + try FileManager.default.moveItem(at: currentFileName, to: currentFileName.appendingPathExtension(READYTOSENDEXTENSION)) + try resetCurrentFile() + } + } + + func writeEvent(event: Event) throws { + try storageQueue.sync { + guard let currentFileHandle = currentFileHandle else { + return + } + let encoder = JSONEncoder() + let serialied = try encoder.encode(event) + let delimiter = "\n".data(using: .utf8) + guard let delimiter else { + return + } + currentFileHandle.seekToEndOfFile() + try currentFileHandle.write(contentsOf: delimiter) + try currentFileHandle.write(contentsOf: serialied) + } + } + + + func batchReadyIds() throws -> [String] { + try storageQueue.sync { + let fileUrls = try FileManager.default.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil) + return fileUrls.filter({ url in url.pathExtension == READYTOSENDEXTENSION }).map({ url in url.lastPathComponent }) + } + } + + func eventsFrom(id: String) throws -> [Event] { + try storageQueue.sync { + let decoder = JSONDecoder() + let fileUrl = folderURL.appendingPathComponent(id) + let data = try Data(contentsOf: fileUrl) + let dataString = String(data: data, encoding: .utf8) + return try dataString?.components(separatedBy: "\n") + .filter({ events in !events.isEmpty }) + .map({ eventString in try decoder.decode(Event.self, from: eventString.data(using: .utf8)!) }) ?? [] + } + } + + func remove(id: String) throws { + try storageQueue.sync { + let fileUrl = folderURL.appendingPathComponent(id) + try FileManager.default.removeItem(at: fileUrl) + } + } + + private func getLastWritingFile() throws -> URL? { + let files = try FileManager.default.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil) + for fileUrl in files { + if fileUrl.pathExtension != READYTOSENDEXTENSION { + return fileUrl + } + } + return nil + } + + private func resetCurrentFile() throws { + // Handling already existing file from previous session + if let currentFile = try getLastWritingFile() { + self.currentFileUrl = currentFile + self.currentFileHandle = try FileHandle(forWritingTo: currentFile) + } else { + let fileUrl = folderURL.appendingPathComponent(String(Date().timeIntervalSince1970)) + FileManager.default.createFile(atPath: fileUrl.path, contents: nil) + self.currentFileUrl = fileUrl + self.currentFileHandle = try FileHandle(forWritingTo: fileUrl) + } + } + + internal static func getFolderURL() throws -> URL { + guard + let applicationSupportUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .last + else { + throw ConfidenceError.cacheError(message: "Could not get URL for application directory") + } + + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { + throw ConfidenceError.cacheError(message: "Unable to get bundle identifier") + } + + return applicationSupportUrl.backport.appending( + components: "com.confidence.events.storage", "\(bundleIdentifier)", "events") + } +} + +struct Event: Encodable, Equatable, Decodable { + let name: String + let payload: [String: ConfidenceValue] + let eventTime: Date +} diff --git a/Sources/ConfidenceProvider/Cache/DefaultStorage.swift b/Sources/ConfidenceProvider/Cache/DefaultStorage.swift index ddf5ed4d..2482a41c 100644 --- a/Sources/ConfidenceProvider/Cache/DefaultStorage.swift +++ b/Sources/ConfidenceProvider/Cache/DefaultStorage.swift @@ -84,7 +84,7 @@ public class DefaultStorage: Storage { func getConfigUrl() throws -> URL { guard - let applicationSupportUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + let applicationSupportUrl: URL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) .last else { throw ConfidenceError.cacheError(message: "Could not get URL for application directory") diff --git a/Tests/ConfidenceTests/EventStorageTests.swift b/Tests/ConfidenceTests/EventStorageTests.swift new file mode 100644 index 00000000..8a07a0e9 --- /dev/null +++ b/Tests/ConfidenceTests/EventStorageTests.swift @@ -0,0 +1,46 @@ +import Foundation +import XCTest + +@testable import Confidence + +class EventStorageTest: XCTestCase { + override func setUp() async throws { + let folderURL = try! EventStorageImpl.getFolderURL() + if FileManager.default.fileExists(atPath: folderURL.path) { + try! FileManager.default.removeItem(at: folderURL) + } + } + + func testCreateNewBatch() throws { + let eventStorage = try EventStorageImpl() + try eventStorage.writeEvent(event: Event(name: "some event", payload: ["pants": ConfidenceValue(string: "green")], eventTime: Date().self)) + try eventStorage.writeEvent(event: Event(name: "some event 2", payload: ["pants": ConfidenceValue(string: "red")], eventTime: Date().self)) + try eventStorage.startNewBatch() + try XCTAssertEqual(eventStorage.batchReadyIds().count, 1) + let events = try eventStorage.eventsFrom(id: try eventStorage.batchReadyIds()[0]) + XCTAssertEqual(events[0].name, "some event") + XCTAssertEqual(events[1].name, "some event 2") + } + + func testContinueWritingToOldBatch() throws { + let eventStorage = try EventStorageImpl() + try eventStorage.writeEvent(event: Event(name: "some event", payload: ["pants": ConfidenceValue(string: "green")], eventTime: Date().self)) + // user stops using app, new session after this + let eventStorageNew = try EventStorageImpl() + try eventStorageNew.writeEvent(event: Event(name: "some event 2", payload: ["pants": ConfidenceValue(string: "red")], eventTime: Date().self)) + try eventStorageNew.startNewBatch() + try XCTAssertEqual(eventStorageNew.batchReadyIds().count, 1) + let events = try eventStorageNew.eventsFrom(id: try eventStorageNew.batchReadyIds()[0]) + XCTAssertEqual(events[0].name, "some event") + XCTAssertEqual(events[1].name, "some event 2") + } + + func testRemoveFile() throws { + let eventStorage = try EventStorageImpl() + try eventStorage.writeEvent(event: Event(name: "some event", payload: ["pants": ConfidenceValue(string: "green")], eventTime: Date().self)) + try eventStorage.writeEvent(event: Event(name: "some event 2", payload: ["pants": ConfidenceValue(string: "red")], eventTime: Date().self)) + try eventStorage.startNewBatch() + try eventStorage.remove(id: eventStorage.batchReadyIds()[0]) + try XCTAssertEqual(eventStorage.batchReadyIds().count, 0) + } +}