Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions Sources/BarUICore/Archive+InferredResets.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation

/// What one Limit's samples have shown so far: every rollover up to the newest
/// paired poll, and enough to carry on from there.
struct DetectedRollovers: Sendable {
let windowMinutes: Int?
/// The newest poll scanned; the next scan pairs it with the first newer one.
let lastPoll: InferredResets.Poll
/// How many reset-time samples were at or before `lastPoll`. A different
/// count later means older samples were imported since, and the whole
/// Limit is scanned again rather than missing a rollover among them.
let samplesThrough: Int
let resets: [LimitReset]
}

extension Archive {
/// Every rollover the Limit's samples show, logged or not, oldest first.
/// Samples are kept forever and polled every few minutes, so this extends
/// what it found last time with newer polls instead of rescanning them all
/// on each refresh.
func detectedRollovers(of app: BarApp, limit: LimitDescriptor) throws -> [LimitReset] {
let key = "\(app.rawValue)/\(limit.id)"
if let cached = rolloverCache[key],
cached.windowMinutes == limit.windowDurationMinutes,
try resetsAtSampleCount(of: app, limitID: limit.id, through: cached.lastPoll.time) == cached.samplesThrough {
let newer = InferredResets.polls(
resetsAt: try samples(of: app, limitID: limit.id, metric: .resetsAt, since: cached.lastPoll.time + 1),
utilization: try samples(of: app, limitID: limit.id, metric: .utilization, since: cached.lastPoll.time + 1)
)
guard let newest = newer.last else {
return cached.resets
}
let found = InferredResets.detected(app: app, limit: limit, in: [cached.lastPoll] + newer)
let updated = DetectedRollovers(
windowMinutes: limit.windowDurationMinutes,
lastPoll: newest,
samplesThrough: try resetsAtSampleCount(of: app, limitID: limit.id, through: newest.time),
resets: cached.resets + found
)
rolloverCache[key] = updated
return updated.resets
}

let polls = InferredResets.polls(
resetsAt: try samples(of: app, limitID: limit.id, metric: .resetsAt, since: Int64.min),
utilization: try samples(of: app, limitID: limit.id, metric: .utilization, since: Int64.min)
)
let found = InferredResets.detected(app: app, limit: limit, in: polls)
if let newest = polls.last {
rolloverCache[key] = DetectedRollovers(
windowMinutes: limit.windowDurationMinutes,
lastPoll: newest,
samplesThrough: try resetsAtSampleCount(of: app, limitID: limit.id, through: newest.time),
resets: found
)
} else {
rolloverCache[key] = nil
}
return found
}

private func resetsAtSampleCount(of app: BarApp, limitID: String, through time: Int64) throws -> Int {
let statement = try prepare("""
SELECT COUNT(*) FROM limit_samples
WHERE app = ?1 AND limit_id = ?2 AND metric = ?3 AND time_unix_nano <= ?4
""")
try statement.bind(.text(app.rawValue), .text(limitID), .text(LimitMetric.resetsAt.rawValue), .integer(time))
return try statement.step() ? Int(statement.int64(at: 0)) : 0
}
}
110 changes: 78 additions & 32 deletions Sources/BarUICore/Archive+Limits.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ extension Archive {
try samples(of: app, limitID: limitID, metric: metric, since: Int64.min)
}

private func samples(of app: BarApp, limitID: String, metric: LimitMetric, since floor: Int64) throws -> [ArchivedSample] {
func samples(of app: BarApp, limitID: String, metric: LimitMetric, since floor: Int64) throws -> [ArchivedSample] {
let statement = try prepare("""
SELECT time_unix_nano, value FROM limit_samples
WHERE app = ?1 AND limit_id = ?2 AND metric = ?3 AND time_unix_nano >= ?4
Expand All @@ -132,9 +132,22 @@ extension Archive {
}

/// Every reset the Archive holds for the Bar App, by Limit ID, oldest
/// first. A Window resets a handful of times a day at most, so the whole
/// list stays small.
/// first: the ones the Bar App logged, plus rollovers its samples show that
/// it never logged. A Window resets a handful of times a day at most, so
/// the whole list stays small.
public func limitResets(of app: BarApp) throws -> [String: [LimitReset]] {
var byLimit = try loggedLimitResets(of: app)
for descriptor in try limitDescriptors(of: app) {
let logged = byLimit[descriptor.id] ?? []
let all = try withInferredResets(logged, of: app, limit: descriptor, since: Int64.min)
if !all.isEmpty {
byLimit[descriptor.id] = all
}
}
return byLimit
}

private func loggedLimitResets(of app: BarApp) throws -> [String: [LimitReset]] {
let statement = try prepare("""
SELECT r.limit_id, r.time_unix_nano, r.utilization_last, r.next_resets_at,
l.name, l.window_minutes, l.model_id
Expand Down Expand Up @@ -171,22 +184,7 @@ extension Archive {
/// A Limit with nothing in the range still comes back, so it can say so.
public func limitHistories(of app: BarApp, since: Date?) throws -> [LimitHistory] {
let floor = since?.unixNano ?? Int64.min
let limits = try prepare("""
SELECT limit_id, name, window_minutes, model_id FROM limits WHERE app = ?1 ORDER BY limit_id
""")
try limits.bind(.text(app.rawValue))
var descriptors: [LimitDescriptor] = []
while try limits.step() {
guard let id = limits.text(at: 0), let name = limits.text(at: 1) else {
continue
}
descriptors.append(LimitDescriptor(
id: id,
name: name,
windowDurationMinutes: limits.optionalInt64(at: 2).map { Int($0) },
modelID: limits.text(at: 3)
))
}
let descriptors = try limitDescriptors(of: app)

let resets = try prepare("""
SELECT time_unix_nano, utilization_last, next_resets_at FROM limit_resets
Expand All @@ -200,7 +198,11 @@ extension Archive {
""")
var histories: [LimitHistory] = []
for descriptor in descriptors {
try resets.bind(.text(app.rawValue), .text(descriptor.id), .integer(floor))
// Read from a Window before the range: the poll before a reset at the
// range's start is needed to recover it, and a reset just before the
// range bounds the peak the range starts from.
let lookback = Self.inferenceFloor(for: floor, limit: descriptor)
try resets.bind(.text(app.rawValue), .text(descriptor.id), .integer(lookback))
Comment thread
GordonBeeming marked this conversation as resolved.
var events: [LimitReset] = []
while try resets.step() {
events.append(LimitReset(
Expand All @@ -215,23 +217,73 @@ extension Archive {
let latestSample = try latest.step()
? ArchivedSample(timeUnixNano: latest.int64(at: 0), value: latest.double(at: 1))
: nil
let all = try withInferredResets(events, of: app, limit: descriptor, since: lookback)
let lastResetBefore = all.last { $0.timeUnixNano < floor }?.timeUnixNano
histories.append(LimitHistory(
app: app,
limit: descriptor,
utilization: try samples(of: app, limitID: descriptor.id, metric: .utilization, since: floor),
latest: latestSample,
resets: events,
peakBeforeRange: since == nil ? nil : try peakBefore(floor, of: app, limit: descriptor)
resets: all.filter { $0.timeUnixNano >= floor },
peakBeforeRange: since == nil ? nil : try peakBefore(floor, of: app, limit: descriptor, lastReset: lastResetBefore)
))
}
return histories
}

private func limitDescriptors(of app: BarApp) throws -> [LimitDescriptor] {
let limits = try prepare("""
SELECT limit_id, name, window_minutes, model_id FROM limits WHERE app = ?1 ORDER BY limit_id
""")
try limits.bind(.text(app.rawValue))
var descriptors: [LimitDescriptor] = []
while try limits.step() {
guard let id = limits.text(at: 0), let name = limits.text(at: 1) else {
continue
}
descriptors.append(LimitDescriptor(
id: id,
name: name,
windowDurationMinutes: limits.optionalInt64(at: 2).map { Int($0) },
modelID: limits.text(at: 3)
))
}
return descriptors
}

/// One Window (and a day's margin) before `floor`, or everything when the
/// Window's length isn't known. The full read is only for a range; an
/// all-time read already starts at the beginning.
static func inferenceFloor(for floor: Int64, limit: LimitDescriptor) -> Int64 {
guard floor != Int64.min, let minutes = limit.windowDurationMinutes else {
return Int64.min
}
let reach = (Int64(minutes) * 60 + 86_400) * 1_000_000_000
return floor > Int64.min + reach ? floor - reach : Int64.min
}

/// The logged resets (read from `floor` on) with the rollovers recovered
/// from the samples from `floor` on that none of them stands for, oldest
/// first.
private func withInferredResets(
_ logged: [LimitReset],
of app: BarApp,
limit: LimitDescriptor,
since floor: Int64
) throws -> [LimitReset] {
let detected = try detectedRollovers(of: app, limit: limit).filter { $0.timeUnixNano >= floor }
let inferred = InferredResets.unlogged(detected, logged: logged, windowMinutes: limit.windowDurationMinutes)
guard !inferred.isEmpty else {
return logged
}
return (logged + inferred).sorted { $0.timeUnixNano < $1.timeUnixNano }
}

/// How high the Window open at `floor` had got before it. Reads back no
/// further than that Window: to its reset, or one Window length when the
/// reset wasn't logged. Without either, the Limit's last earlier sample
/// stands in for the Window.
private func peakBefore(_ floor: Int64, of app: BarApp, limit: LimitDescriptor) throws -> Double? {
/// further than that Window: to its reset (logged or recovered from the
/// samples, `lastReset`), or one Window length when there's none in reach.
/// Without either, the Limit's last earlier sample stands in for the Window.
private func peakBefore(_ floor: Int64, of app: BarApp, limit: LimitDescriptor, lastReset: Int64?) throws -> Double? {
let latest = try prepare("""
SELECT time_unix_nano, value FROM limit_samples
WHERE app = ?1 AND limit_id = ?2 AND metric = ?3 AND time_unix_nano < ?4
Expand All @@ -243,12 +295,6 @@ extension Archive {
}
let lastSample = ArchivedSample(timeUnixNano: latest.int64(at: 0), value: latest.double(at: 1))

let reset = try prepare("""
SELECT MAX(time_unix_nano) FROM limit_resets
WHERE app = ?1 AND limit_id = ?2 AND time_unix_nano < ?3
""")
try reset.bind(.text(app.rawValue), .text(limit.id), .integer(floor))
let lastReset = try reset.step() ? reset.optionalInt64(at: 0) : nil
if let lastReset, lastReset > lastSample.timeUnixNano {
return 0
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/BarUICore/Archive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public actor Archive {

private let connection: Connection
private var database: OpaquePointer { connection.handle }
/// Rollovers detected in each Limit's samples so far, by `app/limit`, so a
/// read only scans polls newer than the last one it saw.
var rolloverCache: [String: DetectedRollovers] = [:]

public init(url: URL) throws {
try FileManager.default.createDirectory(
Expand Down
140 changes: 140 additions & 0 deletions Sources/BarUICore/InferredResets.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import Foundation

/// Window rollovers recovered from a Limit's archived samples, for the ones its
/// Bar App never logged as `bar.limit.reset`. A tool can reset a Window early,
/// days before its reset time, and a Bar App that only expects a Window to run
/// to its end misses those; the samples still show the reset time jumping and
/// usage falling back.
public enum InferredResets {
/// One poll: its time, the reset time it reported (Unix seconds), and usage.
struct Poll: Sendable, Equatable {
let time: Int64
let resetsAt: Double
let usage: Double
}

/// More than a reset time drifts between reads. Weaker signals (a Window
/// already over, a full Window ahead) need a jump past this.
static let jumpTolerance: Double = 3_600
/// The least a reset time must move for a decisive usage drop to count: a
/// short Window reset early moves its deadline only by what had elapsed.
static let minimumJump: Double = 60
/// A rollover this close to a logged reset is that reset, not another one.
/// Never more than half a Window, so a short Window's next rollover stays
/// its own reset.
static let loggedMatch: Int64 = 3_600 * 1_000_000_000
/// Matching the Bar Apps' own reset check: a fall to zero, or a fall of
/// more than this that ends under `usageFloor`.
static let usageDrop: Double = 25
static let usageFloor: Double = 10

/// Every rollover in the samples that no logged reset already covers,
/// oldest first.
public static func rollovers(
app: BarApp,
limit: LimitDescriptor,
resetsAt: [ArchivedSample],
utilization: [ArchivedSample],
logged: [LimitReset]
) -> [LimitReset] {
let found = detected(app: app, limit: limit, in: polls(resetsAt: resetsAt, utilization: utilization))
return unlogged(found, logged: logged, windowMinutes: limit.windowDurationMinutes)
}

/// Reset-time and utilisation samples paired by their exact time, oldest
/// first. A Bar App writes both in the same sample; one without the other
/// is skipped rather than guessed at.
static func polls(resetsAt: [ArchivedSample], utilization: [ArchivedSample]) -> [Poll] {
let usageByTime = Dictionary(utilization.map { ($0.timeUnixNano, $0.value) }, uniquingKeysWith: { _, latest in latest })
return resetsAt
.sorted { $0.timeUnixNano < $1.timeUnixNano }
.compactMap { sample in usageByTime[sample.timeUnixNano].map { Poll(time: sample.timeUnixNano, resetsAt: sample.value, usage: $0) } }
}

/// Every rollover between consecutive polls, whether or not it was logged.
/// Doesn't depend on the logged resets, so a caller can keep these and
/// extend them with newer polls.
static func detected(app: BarApp, limit: LimitDescriptor, in polls: [Poll]) -> [LimitReset] {
var found: [LimitReset] = []
for (before, after) in zip(polls, polls.dropFirst()) {
guard rolledOver(before: before, after: after, windowMinutes: limit.windowDurationMinutes) else {
continue
}
found.append(LimitReset(
app: app,
limit: limit,
timeUnixNano: after.time,
utilizationLast: before.usage,
nextResetsAt: Date(timeIntervalSince1970: after.resetsAt)
))
}
return found
}

/// The detected rollovers no logged reset already stands for.
static func unlogged(_ detected: [LimitReset], logged: [LimitReset], windowMinutes: Int?) -> [LimitReset] {
let radius = matchRadius(windowMinutes: windowMinutes)
let loggedTimes = logged.map(\.timeUnixNano)
return detected.filter { reset in
!loggedTimes.contains { abs($0 - reset.timeUnixNano) <= radius }
}
}

static func matchRadius(windowMinutes: Int?) -> Int64 {
guard let windowMinutes else {
return loggedMatch
}
// Strictly under half a Window, so the next rollover is never matched.
let half = Int64(windowMinutes) * 30 * 1_000_000_000 - 1
return min(loggedMatch, half)
}

static func decisiveDrop(from old: Double, to new: Double) -> Bool {
(old > 0 && new == 0) || (old - new > usageDrop && new < usageFloor)
}

/// The same rule CodexBar logs resets by, plus a Window whose deadline fell
/// between the two polls (the Mac asleep across its reset).
static func rolledOver(before: Poll, after: Poll, windowMinutes: Int?) -> Bool {
// A Window nobody has used floats a full Window ahead of every poll, so
// after a gap, once it expires, or when use finally starts, its reset
// time jumps like a real rollover. At 0% beforehand there was nothing
// to reset, so it never counts.
if before.usage == 0 {
return false
}
let then = Double(before.time) / 1_000_000_000
let now = Double(after.time) / 1_000_000_000
// A deadline already past at the earlier poll was reported stale and is
// only being corrected now; that rollover belongs to the pair before.
if before.resetsAt <= then {
return false
}
let jump = after.resetsAt - before.resetsAt
guard jump >= minimumJump else {
return false
}
let dropped = decisiveDrop(from: before.usage, to: after.usage)
guard let windowMinutes else {
// Without a Window length, only a decisive drop says a jump was a
// reset rather than a reset time being corrected.
return jump > jumpTolerance && dropped
}
let window = Double(windowMinutes) * 60
// A full rollover, or a decisive drop with the deadline moving, counts
// whatever the Window's length, so a short Window isn't held to the
// hour of drift tolerance.
if jump >= window / 2 || dropped {
return true
}
guard jump > jumpTolerance else {
return false
}
if before.resetsAt <= now {
return true
}
// A full Window ahead alone could still be a floating Window; usage
// going down is what tells a real reset apart.
return after.usage < before.usage && abs(after.resetsAt - now - window) <= jumpTolerance
}
}
Loading
Loading