events/ → events.log; EventStore takes a file URL directly. Open copies follow the new name.
118 lines
4.2 KiB
Swift
118 lines
4.2 KiB
Swift
import Foundation
|
|
|
|
public actor EventStore {
|
|
public static let defaultByteCap: Int = 64 * 1024 * 1024
|
|
public static let compactionLowWatermark: Double = 0.75
|
|
|
|
private let fileURL: URL
|
|
private let byteCap: Int
|
|
private var fileSize: Int
|
|
|
|
public init(fileURL: URL, byteCap: Int = defaultByteCap) {
|
|
self.fileURL = fileURL
|
|
self.byteCap = byteCap
|
|
self.fileSize = (try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int) ?? 0
|
|
}
|
|
|
|
public func append(_ events: [RuntimeEvent]) {
|
|
guard !events.isEmpty else { return }
|
|
let payload = encodeBatch(events)
|
|
guard !payload.isEmpty else { return }
|
|
appendBytes(payload)
|
|
if fileSize > byteCap {
|
|
compact()
|
|
}
|
|
}
|
|
|
|
public func loadAll() -> [RuntimeEvent] {
|
|
guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return [] }
|
|
defer { try? handle.close() }
|
|
return decodeStream(handle)
|
|
}
|
|
|
|
public func clear() {
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
fileSize = 0
|
|
}
|
|
|
|
private func encodeBatch(_ events: [RuntimeEvent]) -> Data {
|
|
var out = Data()
|
|
for event in events {
|
|
guard let obj = event.toLogJSON(),
|
|
let bytes = try? JSONSerialization.data(withJSONObject: obj, options: [])
|
|
else { continue }
|
|
appendRecord(bytes, to: &out)
|
|
}
|
|
return out
|
|
}
|
|
|
|
private func appendRecord(_ payload: Data, to output: inout Data) {
|
|
var lengthBE = UInt32(payload.count).bigEndian
|
|
withUnsafeBytes(of: &lengthBE) { output.append(contentsOf: $0) }
|
|
output.append(payload)
|
|
}
|
|
|
|
private func appendBytes(_ bytes: Data) {
|
|
if let handle = try? FileHandle(forWritingTo: fileURL) {
|
|
defer { try? handle.close() }
|
|
_ = try? handle.seekToEnd()
|
|
try? handle.write(contentsOf: bytes)
|
|
} else {
|
|
try? bytes.write(to: fileURL, options: .atomic)
|
|
}
|
|
fileSize += bytes.count
|
|
}
|
|
|
|
private func compact() {
|
|
guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return }
|
|
defer { try? handle.close() }
|
|
|
|
let target = Int(Double(byteCap) * Self.compactionLowWatermark)
|
|
guard let dropOffset = findDropOffset(handle: handle, target: target) else { return }
|
|
guard dropOffset > 0 else { return }
|
|
|
|
let tempURL = fileURL.deletingLastPathComponent()
|
|
.appendingPathComponent("events.log.\(UUID().uuidString).tmp")
|
|
try? handle.seek(toOffset: UInt64(dropOffset))
|
|
guard let tail = try? handle.readToEnd() else { return }
|
|
do {
|
|
try tail.write(to: tempURL, options: .atomic)
|
|
_ = try FileManager.default.replaceItemAt(fileURL, withItemAt: tempURL)
|
|
fileSize = tail.count
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: tempURL)
|
|
}
|
|
}
|
|
|
|
private func findDropOffset(handle: FileHandle, target: Int) -> Int? {
|
|
try? handle.seek(toOffset: 0)
|
|
var offset = 0
|
|
let total = fileSize
|
|
while total - offset > target {
|
|
try? handle.seek(toOffset: UInt64(offset))
|
|
guard let header = try? handle.read(upToCount: 4), header.count == 4 else { return nil }
|
|
let length = Int(readUInt32BE(header))
|
|
try? handle.seek(toOffset: UInt64(offset + 4 + length))
|
|
offset += 4 + length
|
|
}
|
|
return offset
|
|
}
|
|
|
|
private func decodeStream(_ handle: FileHandle) -> [RuntimeEvent] {
|
|
var events: [RuntimeEvent] = []
|
|
while let header = try? handle.read(upToCount: 4), header.count == 4 {
|
|
let length = Int(readUInt32BE(header))
|
|
guard let body = try? handle.read(upToCount: length), body.count == length else { break }
|
|
guard let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
|
|
let event = RuntimeEvent.fromLogJSON(obj)
|
|
else { continue }
|
|
events.append(event)
|
|
}
|
|
return events
|
|
}
|
|
|
|
private func readUInt32BE(_ data: Data) -> UInt32 {
|
|
data.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) }.bigEndian
|
|
}
|
|
}
|