Files
luma/Sources/LumaCore/Persistence/EventStore.swift
Ole André Vadla Ravnås c9fc806c10 Persist event log as a single file
events/ → events.log; EventStore takes a file URL directly. Open
copies follow the new name.
2026-05-20 19:16:41 +02:00

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
}
}