Skip to content

Commit

Permalink
feat: add EventStorage (#87)
Browse files Browse the repository at this point in the history
* feat: add EventStorage

Co-authored-by: Nicklas Lundin <nicklasl@spotify.com>
Co-authored-by: vahid torkaman <vahidt@spotify.com>
  • Loading branch information
3 people authored Apr 15, 2024
1 parent b223804 commit fdc7543
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 37 deletions.
10 changes: 0 additions & 10 deletions ConfidenceDemoApp/ConfidenceDemoAppTests/ConfidenceDemoTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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 {
Expand Down
8 changes: 1 addition & 7 deletions Sources/Confidence/EventSenderEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 0 additions & 8 deletions Sources/Confidence/EventSenderStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
126 changes: 126 additions & 0 deletions Sources/Confidence/EventStorage.swift
Original file line number Diff line number Diff line change
@@ -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))

Check warning on line 33 in Sources/Confidence/EventStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 131 characters (line_length)
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 })

Check warning on line 59 in Sources/Confidence/EventStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 126 characters (line_length)

Check warning on line 59 in Sources/Confidence/EventStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Closure Violation: Trailing closure syntax should be used whenever possible (trailing_closure)

Check warning on line 59 in Sources/Confidence/EventStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Closure Violation: Trailing closure syntax should be used whenever possible (trailing_closure)
}
}

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")

Check warning on line 69 in Sources/Confidence/EventStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Closure Violation: Trailing closure syntax should be used whenever possible (trailing_closure)

Check warning on line 69 in Sources/Confidence/EventStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Closure Violation: Trailing closure syntax should be used whenever possible (trailing_closure)
.filter({ events in !events.isEmpty })
.map({ eventString in try decoder.decode(Event.self, from: eventString.data(using: .utf8)!) }) ?? []

Check warning on line 71 in Sources/Confidence/EventStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Force Unwrapping Violation: Force unwrapping should be avoided (force_unwrapping)
}
}

func remove(id: String) throws {
try storageQueue.sync {
let fileUrl = folderURL.appendingPathComponent(id)

Check warning on line 77 in Sources/Confidence/EventStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Indentation Width Violation: Code should be indented using one tab or 4 spaces (indentation_width)
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 {

Check warning on line 85 in Sources/Confidence/EventStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Prefer For-Where Violation: `where` clauses are preferred over a single `if` inside a `for` (for_where)
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
}
2 changes: 1 addition & 1 deletion Sources/ConfidenceProvider/Cache/DefaultStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
46 changes: 46 additions & 0 deletions Tests/ConfidenceTests/EventStorageTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit fdc7543

Please sign in to comment.