mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
Profiling a 2,745-session hub while scrolling a chat showed the two longest main-thread stalls (~80 ms) came from the session list, not the transcript: - The home list, covered by the pushed chat, still rebuilt and diffed every row on each session change. A covered list now keeps its last rows and refreshes once on return; a side-by-side split stays live. - DiskCache encoded the 2 MB session snapshot on the main actor after every burst of changes. Encoding now runs with the file write, off it. After the change the trace has no session-list or snapshot work on the main thread and no stall over 50 ms (max 38 ms, from Markdown cells entering the screen).
113 lines
4.5 KiB
Swift
113 lines
4.5 KiB
Swift
import Foundation
|
|
|
|
/// Generic debounced JSON snapshot: one value ↔ one file. Backs the stores'
|
|
/// cold-start cache (no database, per the plan) — load synchronously at
|
|
/// construction, ``scheduleWrite(_:)`` on every state change, and the
|
|
/// debounce collapses SSE bursts into one atomic write.
|
|
///
|
|
/// Mirrors the Android reference port (`JsonSnapshotStore`): atomicity comes
|
|
/// from `Data.write(options: .atomic)` (write-to-temp + rename, Foundation's
|
|
/// equivalent of androidx `AtomicFile`), and a torn/corrupt snapshot degrades
|
|
/// to `nil` on ``load()`` — the stores then start empty and a REST refetch
|
|
/// repopulates.
|
|
///
|
|
/// `@MainActor` by design: the owning stores are main-actor `@Observable`
|
|
/// classes, so the pending-value bookkeeping needs no locking; encoding and
|
|
/// the file write hop off the main actor (a large hub's session list is
|
|
/// megabytes of JSON — tens of ms per flush on the main thread).
|
|
@MainActor
|
|
public final class DiskCache<Value: Codable & Sendable> {
|
|
/// Snapshot location: `<directory>/<filename>`.
|
|
public let fileURL: URL
|
|
|
|
private let debounce: Duration
|
|
private var pending: Value?
|
|
/// Bumped by every ``scheduleWrite(_:)``; a flush clears `pending` only
|
|
/// when no newer value was scheduled while its write was in flight
|
|
/// (value-type stand-in for the reference's identity comparison).
|
|
private var generation = 0
|
|
private var writeTask: Task<Void, Never>?
|
|
|
|
/// The debounce defaults to 500 ms (the reference's
|
|
/// `DEFAULT_DEBOUNCE_MS`); tests inject shorter windows.
|
|
public init(
|
|
directory: URL,
|
|
filename: String,
|
|
debounce: Duration = .milliseconds(500)
|
|
) {
|
|
self.fileURL = directory.appendingPathComponent(filename, isDirectory: false)
|
|
self.debounce = debounce
|
|
}
|
|
|
|
/// Synchronous load for cold start; `nil` when absent or undecodable.
|
|
public func load() -> Value? {
|
|
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
|
return try? HapiJSON.decoder.decode(Value.self, from: data)
|
|
}
|
|
|
|
/// Records `value` as the latest state and (re)starts the debounce window.
|
|
public func scheduleWrite(_ value: Value) {
|
|
pending = value
|
|
generation += 1
|
|
writeTask?.cancel()
|
|
writeTask = Task { [debounce] in
|
|
do {
|
|
try await Task.sleep(for: debounce)
|
|
} catch {
|
|
return // superseded by a newer scheduleWrite or a flush
|
|
}
|
|
await self.flushPending()
|
|
}
|
|
}
|
|
|
|
/// Writes any pending value immediately (app background / tests).
|
|
public func flush() async {
|
|
writeTask?.cancel()
|
|
writeTask = nil
|
|
await flushPending()
|
|
}
|
|
|
|
private func flushPending() async {
|
|
guard let value = pending else { return }
|
|
let flushedGeneration = generation
|
|
let url = fileURL
|
|
await Task.detached(priority: .utility) {
|
|
do {
|
|
let data = try HapiJSON.encoder.encode(value)
|
|
try FileManager.default.createDirectory(
|
|
at: url.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
try data.write(to: url, options: .atomic)
|
|
} catch {
|
|
// A failed snapshot write only costs the next cold start.
|
|
}
|
|
}.value
|
|
// Cleared only when no newer value arrived during the write — a
|
|
// superseding scheduleWrite keeps its value pending for its own
|
|
// flush (same guarantee as the reference's identity check).
|
|
if generation == flushedGeneration {
|
|
pending = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where per-hub snapshots live:
|
|
/// `<Application Support>/HapiKit/hubs/<encoded-origin>/`.
|
|
///
|
|
/// The base directory is injectable (tests use a temp dir); the origin is
|
|
/// percent-encoded down to alphanumerics so it is filesystem-safe and
|
|
/// deterministic per hub.
|
|
public enum SnapshotLocations {
|
|
public static func directory(forHub hubUrl: String, base: URL? = nil) -> URL {
|
|
let root = base
|
|
?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
?? FileManager.default.temporaryDirectory
|
|
let encoded = hubUrl.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "hub"
|
|
return root
|
|
.appendingPathComponent("HapiKit", isDirectory: true)
|
|
.appendingPathComponent("hubs", isDirectory: true)
|
|
.appendingPathComponent(encoded, isDirectory: true)
|
|
}
|
|
}
|