Cache task events on disk per server; fix performance audit findings
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
|
||||
private let encoder: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.dateEncodingStrategy = .secondsSince1970
|
||||
return e
|
||||
}()
|
||||
|
||||
private let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .secondsSince1970
|
||||
return d
|
||||
}()
|
||||
|
||||
final class EventCache: @unchecked Sendable {
|
||||
private let dir: URL
|
||||
private let queue = DispatchQueue(label: "run.fc.byre.events")
|
||||
private var lastSeqs: [String: Int64] = [:]
|
||||
private var generation = 0
|
||||
|
||||
init(host: String) {
|
||||
dir = URL.applicationSupportDirectory.appending(path: "run.fc.byre.mac/events/\(host)", directoryHint: .isDirectory)
|
||||
}
|
||||
|
||||
func open(schema: String) {
|
||||
queue.async {
|
||||
self.generation += 1
|
||||
let fm = FileManager.default
|
||||
let marker = self.dir.appending(path: "schema")
|
||||
if (try? String(contentsOf: marker, encoding: .utf8)) == schema {
|
||||
return
|
||||
}
|
||||
self.lastSeqs = [:]
|
||||
try? fm.removeItem(at: self.dir)
|
||||
do {
|
||||
try fm.createDirectory(at: self.dir, withIntermediateDirectories: true)
|
||||
try schema.write(to: marker, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
log.error("open event cache: \(errorText(error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func load(_ taskId: String) async -> (events: [ByreEvent], generation: Int) {
|
||||
await withCheckedContinuation { c in
|
||||
queue.async {
|
||||
let events = self.read(taskId)
|
||||
self.lastSeqs[taskId] = events.last?.seq ?? 0
|
||||
c.resume(returning: (events, self.generation))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func append(_ taskId: String, _ events: [ByreEvent], generation: Int) {
|
||||
queue.async {
|
||||
guard generation == self.generation, let last = self.lastSeqs[taskId] else {
|
||||
return
|
||||
}
|
||||
let fresh = events.filter { $0.seq > last }
|
||||
guard let newest = fresh.last else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try self.write(taskId, fresh)
|
||||
self.lastSeqs[taskId] = newest.seq
|
||||
} catch {
|
||||
log.error("cache events of \(taskId, privacy: .public): \(errorText(error), privacy: .public)")
|
||||
self.forget(taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drop(_ taskId: String) {
|
||||
queue.async {
|
||||
self.forget(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
func keep(_ taskIds: Set<String>) {
|
||||
queue.async {
|
||||
let files = (try? FileManager.default.contentsOfDirectory(at: self.dir, includingPropertiesForKeys: nil)) ?? []
|
||||
for url in files where url.pathExtension == "ndjson" {
|
||||
let taskId = url.deletingPathExtension().lastPathComponent
|
||||
if !taskIds.contains(taskId) {
|
||||
self.forget(taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func file(_ taskId: String) -> URL {
|
||||
dir.appending(path: "\(taskId).ndjson")
|
||||
}
|
||||
|
||||
private func forget(_ taskId: String) {
|
||||
lastSeqs[taskId] = nil
|
||||
try? FileManager.default.removeItem(at: file(taskId))
|
||||
}
|
||||
|
||||
private func read(_ taskId: String) -> [ByreEvent] {
|
||||
let url = file(taskId)
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
return []
|
||||
}
|
||||
var events: [ByreEvent] = []
|
||||
var kept = 0
|
||||
for line in data.split(separator: 0x0a, omittingEmptySubsequences: false) where !line.isEmpty {
|
||||
guard let ev = try? decoder.decode(ByreEvent.self, from: line) else {
|
||||
break
|
||||
}
|
||||
events.append(ev)
|
||||
kept = min(line.endIndex + 1, data.endIndex) - data.startIndex
|
||||
}
|
||||
if kept < data.count, let h = try? FileHandle(forWritingTo: url) {
|
||||
try? h.truncate(atOffset: UInt64(kept))
|
||||
try? h.close()
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
private func write(_ taskId: String, _ events: [ByreEvent]) throws {
|
||||
var data = Data()
|
||||
for ev in events {
|
||||
data.append(try encoder.encode(ev))
|
||||
data.append(0x0a)
|
||||
}
|
||||
let url = file(taskId)
|
||||
let fm = FileManager.default
|
||||
if !fm.fileExists(atPath: url.path), !fm.createFile(atPath: url.path, contents: nil) {
|
||||
throw ApiError(status: 0, message: "create \(url.path)")
|
||||
}
|
||||
let h = try FileHandle(forWritingTo: url)
|
||||
defer {
|
||||
try? h.close()
|
||||
}
|
||||
try h.seekToEnd()
|
||||
try h.write(contentsOf: data)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ final class Feed {
|
||||
@ObservationIgnored private var scheduled = false
|
||||
@ObservationIgnored private var toolNames: [String: String] = [:]
|
||||
@ObservationIgnored private let shown: () -> Bool
|
||||
@ObservationIgnored private var persist: ([ByreEvent]) -> Void = { _ in }
|
||||
|
||||
init(shown: @escaping () -> Bool) {
|
||||
self.shown = shown
|
||||
@@ -60,12 +61,13 @@ final class Feed {
|
||||
}
|
||||
}
|
||||
|
||||
func load(_ history: [ByreEvent], inherited: Int = 0) {
|
||||
func load(_ history: [ByreEvent], inherited: Int = 0, persist: @escaping ([ByreEvent]) -> Void = { _ in }) {
|
||||
if ready {
|
||||
return
|
||||
}
|
||||
ready = true
|
||||
self.inherited = inherited
|
||||
self.persist = persist
|
||||
buffer = history + buffer
|
||||
flush()
|
||||
}
|
||||
@@ -131,6 +133,7 @@ final class Feed {
|
||||
return
|
||||
}
|
||||
events.append(contentsOf: fresh)
|
||||
persist(fresh)
|
||||
let found = findCaptures(fresh)
|
||||
if !found.isEmpty {
|
||||
captures.append(contentsOf: found)
|
||||
|
||||
@@ -173,6 +173,7 @@ final class Connection {
|
||||
let overlays = Overlays()
|
||||
let forwards: Forwards
|
||||
let pages: Pages
|
||||
let cache: EventCache
|
||||
var phase: Phase = .starting
|
||||
@ObservationIgnored private var stopped = false
|
||||
@ObservationIgnored private var restarting = false
|
||||
@@ -181,6 +182,7 @@ final class Connection {
|
||||
self.origin = origin
|
||||
forwards = Forwards(host: origin.host()!)
|
||||
pages = Pages(api: API(origin: origin))
|
||||
cache = EventCache(host: origin.host()!)
|
||||
Task {
|
||||
await start()
|
||||
}
|
||||
@@ -231,9 +233,10 @@ final class Connection {
|
||||
}
|
||||
|
||||
private func attempt(_ old: Store?) async -> Bool {
|
||||
let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts(), forwards: forwards, pages: pages)
|
||||
let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts(), forwards: forwards, pages: pages, cache: cache)
|
||||
do {
|
||||
let status = try await store.api.client.getStatus().ok.body.json
|
||||
cache.open(schema: status.schema)
|
||||
async let me: Void = store.loadMe()
|
||||
async let projects: Void = store.loadProjects()
|
||||
async let inbox: Void = store.loadInbox()
|
||||
@@ -254,6 +257,7 @@ final class Connection {
|
||||
store.startGlobalStream(after: status.lastEventSeq) { [weak self] in
|
||||
self?.reconnected(status.startedAt)
|
||||
}
|
||||
store.startFeeds()
|
||||
store.remote.start()
|
||||
forwards.sync(store.api, Set(store.activeTasks().map(\.task.id)))
|
||||
Notifier.shared.deliver()
|
||||
|
||||
@@ -92,9 +92,11 @@ final class Store {
|
||||
let remote: Remote
|
||||
let forwards: Forwards
|
||||
let pages: Pages
|
||||
let cache: EventCache
|
||||
|
||||
private var taskFeeds: [String: Feed] = [:]
|
||||
@ObservationIgnored private var historyLoads: Set<ObjectIdentifier> = []
|
||||
@ObservationIgnored private var feedsStarted = false
|
||||
@ObservationIgnored private var requestSeqs: [String: Int] = [:]
|
||||
@ObservationIgnored private var taskReloads: Set<String> = []
|
||||
@ObservationIgnored private var listReloads: Set<String> = []
|
||||
@@ -109,11 +111,12 @@ final class Store {
|
||||
let changesMemory = ChangesMemory()
|
||||
@ObservationIgnored private var localSends: [String: LocalSends] = [:]
|
||||
|
||||
init(api: API, images: ImageDrafts, forwards: Forwards, pages: Pages) {
|
||||
init(api: API, images: ImageDrafts, forwards: Forwards, pages: Pages, cache: EventCache) {
|
||||
self.api = api
|
||||
self.images = images
|
||||
self.forwards = forwards
|
||||
self.pages = pages
|
||||
self.cache = cache
|
||||
leadFeed = Feed(shown: { true })
|
||||
paneFocus = PaneFocus(api: api)
|
||||
remote = Remote(api: api)
|
||||
@@ -443,8 +446,10 @@ final class Store {
|
||||
|
||||
private func applyTasks(_ projectId: String, _ list: [ByreTask]) async throws {
|
||||
tasks[projectId] = list.sorted { $0.createdAt > $1.createdAt }
|
||||
for t in list where t.status == .active && !t.background {
|
||||
_ = taskFeed(t.id)
|
||||
if feedsStarted {
|
||||
for t in list where t.status == .active && !t.background {
|
||||
_ = taskFeed(t.id)
|
||||
}
|
||||
}
|
||||
dropClosedFeeds()
|
||||
drafts?.prune(self)
|
||||
@@ -622,7 +627,8 @@ final class Store {
|
||||
}
|
||||
|
||||
private func loadHistory(_ taskId: String, _ feed: Feed) async throws {
|
||||
var got: [ByreEvent] = []
|
||||
let (cached, generation) = await cache.load(taskId)
|
||||
var got = cached
|
||||
while true {
|
||||
let page = try await api.client.listTaskEvents(path: .init(taskId: taskId), query: .init(after: got.last?.seq ?? 0, limit: historyPage)).ok.body.json
|
||||
got += page
|
||||
@@ -630,7 +636,18 @@ final class Store {
|
||||
break
|
||||
}
|
||||
}
|
||||
feed.load(got, inherited: got.prefix { $0.taskId != taskId }.count)
|
||||
feed.load(got, inherited: got.prefix { $0.taskId != taskId }.count, persist: { [cache] events in
|
||||
cache.append(taskId, events, generation: generation)
|
||||
})
|
||||
}
|
||||
|
||||
func startFeeds() {
|
||||
feedsStarted = true
|
||||
let active = activeTasks()
|
||||
cache.keep(Set(active.map(\.task.id)))
|
||||
for (_, task) in active where !task.background {
|
||||
_ = taskFeed(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func taskFeed(_ taskId: String) -> Feed {
|
||||
@@ -658,6 +675,7 @@ final class Store {
|
||||
for taskId in taskFeeds.keys {
|
||||
if taskId != selectedTaskId, let task = findTask(taskId), task.status != .active {
|
||||
taskFeeds[taskId] = nil
|
||||
cache.drop(taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user