diff --git a/Sources/ContextPanelCore/ContextPanelLocations.swift b/Sources/ContextPanelCore/ContextPanelLocations.swift index d03f7c9..c41d2ee 100644 --- a/Sources/ContextPanelCore/ContextPanelLocations.swift +++ b/Sources/ContextPanelCore/ContextPanelLocations.swift @@ -194,6 +194,16 @@ public enum ContextPanelLocations { return applicationSupportDirectory().appending(path: "refresh-diagnostics-state.json") } + public static func resetExpiryRefreshStateURL(appGroupID: String? = nil) -> URL { + if let containerURL = appGroupContainerURL(appGroupID: appGroupID) { + return containerURL + .appending(path: "Context Panel", directoryHint: .isDirectory) + .appending(path: "reset-expiry-refresh-state.json") + } + + return applicationSupportDirectory().appending(path: "reset-expiry-refresh-state.json") + } + public static func limitWarningSettingsURL(appGroupID: String? = nil) -> URL { if let containerURL = appGroupContainerURL(appGroupID: appGroupID) { return containerURL diff --git a/Sources/ContextPanelCore/ResetExpiryRefreshState.swift b/Sources/ContextPanelCore/ResetExpiryRefreshState.swift new file mode 100644 index 0000000..3d4803d --- /dev/null +++ b/Sources/ContextPanelCore/ResetExpiryRefreshState.swift @@ -0,0 +1,355 @@ +import Foundation + +public struct ResetExpiryRefreshKey: Codable, Equatable, Hashable, Sendable { + public let provider: Provider + public let accountID: String + public let configuredAccountID: String? + public let limitID: String + public let resetsAt: Date + + public init( + provider: Provider, + accountID: String, + configuredAccountID: String?, + limitID: String, + resetsAt: Date + ) { + self.provider = provider + self.accountID = accountID + self.configuredAccountID = configuredAccountID + self.limitID = limitID + self.resetsAt = resetsAt + } + + public init?(limit: UsageLimit) { + guard let resetsAt = limit.resetsAt else { return nil } + self.init( + provider: limit.provider, + accountID: limit.accountID, + configuredAccountID: limit.configuredAccountID, + limitID: limit.id, + resetsAt: resetsAt + ) + } +} + +public struct ResetExpiryRefreshRecord: Codable, Equatable, Sendable { + public var key: ResetExpiryRefreshKey + public var attemptedAt: Date + public var nextRetryAt: Date? + public var retryCount: Int + + public init( + key: ResetExpiryRefreshKey, + attemptedAt: Date, + nextRetryAt: Date?, + retryCount: Int + ) { + self.key = key + self.attemptedAt = attemptedAt + self.nextRetryAt = nextRetryAt + self.retryCount = retryCount + } + + public var identity: String { key.identity } +} + +public struct ResetExpiryRefreshState: Codable, Equatable, Sendable { + public let schemaVersion: Int + public var records: [ResetExpiryRefreshRecord] + + enum CodingKeys: String, CodingKey { + case schemaVersion + case records + } + + public init(records: [ResetExpiryRefreshRecord] = []) { + schemaVersion = 1 + self.records = records + } + + public static var empty: ResetExpiryRefreshState { ResetExpiryRefreshState() } + + public func record(for key: ResetExpiryRefreshKey) -> ResetExpiryRefreshRecord? { + records.first { $0.key == key } + } + + public func nextRetryDate(for snapshot: UsageSnapshot, now: Date = Date()) -> Date? { + let dueIdentities = Set(snapshot.resetRefreshDueKeys(now: now).map(\.identity)) + return records + .filter { dueIdentities.contains($0.identity) } + .compactMap(\.nextRetryAt) + .min() + } + + public func nextRefreshCheckDate(for snapshot: UsageSnapshot, now: Date = Date()) -> Date? { + snapshot.limits.compactMap { limit -> Date? in + guard let resetRefreshDate = limit.nextResetRefreshDate(now: now) else { return nil } + guard resetRefreshDate <= now else { return resetRefreshDate } + guard let key = ResetExpiryRefreshKey(limit: limit) else { return nil } + guard let record = record(for: key) else { return now } + guard let nextRetryAt = record.nextRetryAt else { return nil } + return max(nextRetryAt, now) + }.min() + } + + public mutating func recordAttempt( + previousSnapshot: UsageSnapshot?, + refreshedSnapshot: UsageSnapshot, + attemptedAccounts: Set? = nil, + attemptedAt: Date, + retryDelay: TimeInterval = SnapshotFreshness.resetExpiryRetryDelay + ) { + guard let previousSnapshot else { + prune(for: refreshedSnapshot) + return + } + + let previousDueKeys = Set(previousSnapshot.resetRefreshDueKeys(now: attemptedAt)) + let refreshedDueKeys = Set(refreshedSnapshot.resetRefreshDueKeys(now: attemptedAt)) + let attemptedPreviousIdentities = Set(previousDueKeys.filter { key in + guard let attemptedAccounts else { return true } + return attemptedAccounts.contains { $0.matches(key) } + }.map(\.identity)) + let liveIdentities = Set(refreshedSnapshot.limits.compactMap { ResetExpiryRefreshKey(limit: $0)?.identity }) + let stuckKeys = previousDueKeys.intersection(refreshedDueKeys).filter { key in + attemptedPreviousIdentities.contains(key.identity) + } + let existingByIdentity = Dictionary(uniqueKeysWithValues: records.map { ($0.identity, $0) }) + + let preservedRecords = records.filter { record in + !attemptedPreviousIdentities.contains(record.identity) && liveIdentities.contains(record.identity) + } + records = preservedRecords + stuckKeys.sortedByIdentity.map { key in + let existing = existingByIdentity[key.identity] + let retryCount = (existing?.retryCount ?? 0) + 1 + return ResetExpiryRefreshRecord( + key: key, + attemptedAt: attemptedAt, + nextRetryAt: retryCount == 1 ? attemptedAt.addingTimeInterval(retryDelay) : nil, + retryCount: retryCount + ) + } + } + + public mutating func recordAttemptWithoutSnapshot( + previousSnapshot: UsageSnapshot?, + attemptedAt: Date, + retryDelay: TimeInterval = SnapshotFreshness.resetExpiryRetryDelay + ) { + guard let previousSnapshot else { return } + let stuckKeys = previousSnapshot.resetRefreshDueKeys(now: attemptedAt) + guard !stuckKeys.isEmpty else { + records.removeAll() + return + } + let existingByIdentity = Dictionary(uniqueKeysWithValues: records.map { ($0.identity, $0) }) + records = stuckKeys.sortedByIdentity.map { key in + let existing = existingByIdentity[key.identity] + let retryCount = (existing?.retryCount ?? 0) + 1 + return ResetExpiryRefreshRecord( + key: key, + attemptedAt: attemptedAt, + nextRetryAt: retryCount == 1 ? attemptedAt.addingTimeInterval(retryDelay) : nil, + retryCount: retryCount + ) + } + } + + public mutating func deferDueResets( + previousSnapshot: UsageSnapshot?, + attemptedAt: Date, + retryDelay: TimeInterval = SnapshotFreshness.resetExpiryRetryDelay + ) { + guard let previousSnapshot else { return } + let dueKeys = previousSnapshot.resetRefreshDueKeys(now: attemptedAt) + guard !dueKeys.isEmpty else { return } + let existingByIdentity = Dictionary(uniqueKeysWithValues: records.map { ($0.identity, $0) }) + let deferredRecords = dueKeys.sortedByIdentity.map { key in + let existing = existingByIdentity[key.identity] + return ResetExpiryRefreshRecord( + key: key, + attemptedAt: existing?.attemptedAt ?? attemptedAt, + nextRetryAt: attemptedAt.addingTimeInterval(retryDelay), + retryCount: existing?.retryCount ?? 0 + ) + } + let deferredIdentities = Set(deferredRecords.map(\.identity)) + records.removeAll { deferredIdentities.contains($0.identity) } + records.append(contentsOf: deferredRecords) + } + + public mutating func prune(for snapshot: UsageSnapshot) { + let liveIdentities = Set(snapshot.limits.compactMap { ResetExpiryRefreshKey(limit: $0)?.identity }) + records.removeAll { !liveIdentities.contains($0.identity) } + } +} + +public struct ResetExpiryRefreshAccountKey: Equatable, Hashable, Sendable { + public let provider: Provider + public let accountID: String + public let configuredAccountID: String? + + public init(provider: Provider, accountID: String, configuredAccountID: String?) { + self.provider = provider + self.accountID = accountID + self.configuredAccountID = configuredAccountID + } + + public init(key: ResetExpiryRefreshKey) { + self.init( + provider: key.provider, + accountID: key.accountID, + configuredAccountID: key.configuredAccountID + ) + } + + public init(report: ProviderConnectorReport) { + self.init( + provider: report.provider, + accountID: report.accountID, + configuredAccountID: report.configuredAccountID + ) + } + + public func matches(_ key: ResetExpiryRefreshKey) -> Bool { + guard provider == key.provider else { return false } + if let configuredAccountID { + return configuredAccountID == key.configuredAccountID || accountID == key.accountID + } + return accountID == key.accountID + } +} + +public struct ResetExpiryRefreshStateStore: Sendable { + public let stateURL: URL + + public init(stateURL: URL) { + self.stateURL = stateURL + } + + public static func appDefault() -> ResetExpiryRefreshStateStore { + ResetExpiryRefreshStateStore( + stateURL: ContextPanelLocations.resetExpiryRefreshStateURL(appGroupID: ContextPanelLocations.appGroupID) + ) + } + + public func load() -> ResetExpiryRefreshState { + loadIfAvailable() ?? .empty + } + + public func loadIfAvailable() -> ResetExpiryRefreshState? { + guard FileManager.default.fileExists(atPath: stateURL.path) else { + return nil + } + + do { + let state = try Self.makeDecoder().decode( + ResetExpiryRefreshState.self, + from: try Data(contentsOf: stateURL) + ) + guard state.schemaVersion == 1 else { return nil } + return state + } catch { + return nil + } + } + + public func save(_ state: ResetExpiryRefreshState) throws { + try withExclusiveLock { + try saveUnlocked(state) + } + } + + public func update(_ body: (inout ResetExpiryRefreshState) -> Void) throws { + try withExclusiveLock { + var state = loadIfAvailable() ?? .empty + body(&state) + try saveUnlocked(state) + } + } + + private func saveUnlocked(_ state: ResetExpiryRefreshState) throws { + let directory = stateURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try Self.makeEncoder().encode(state) + try data.write(to: stateURL, options: [.atomic]) + } + + private func withExclusiveLock(_ body: () throws -> T) throws -> T { + var coordinationError: NSError? + var operationError: Error? + var result: Result? + NSFileCoordinator(filePresenter: nil).coordinate( + writingItemAt: stateURL, + options: .forReplacing, + error: &coordinationError + ) { _ in + do { + result = .success(try body()) + } catch { + operationError = error + result = .failure(error) + } + } + if let operationError { throw operationError } + if let coordinationError { throw coordinationError } + guard let result else { throw CocoaError(.fileWriteUnknown) } + return try result.get() + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} + +public extension UsageSnapshot { + func resetRefreshDueKeys(now: Date = Date()) -> [ResetExpiryRefreshKey] { + limits.compactMap { limit in + guard limit.isResetRefreshDue(now: now) else { return nil } + return ResetExpiryRefreshKey(limit: limit) + } + } + + func nextResetRefreshDate(now: Date = Date()) -> Date? { + limits.compactMap { $0.nextResetRefreshDate(now: now) }.min() + } +} + +public extension UsageLimit { + func isResetRefreshDue(now: Date = Date()) -> Bool { + guard let resetRefreshDate = nextResetRefreshDate(now: now) else { return false } + return resetRefreshDate <= now + } + + func nextResetRefreshDate(now: Date = Date()) -> Date? { + guard let resetsAt else { return nil } + guard status != .failure, status != .unknown, status != .stale else { return nil } + guard used != nil || limit != nil else { return nil } + guard resetsAt > (lastUpdatedAt ?? .distantPast) else { return nil } + return resetsAt.addingTimeInterval(SnapshotFreshness.resetExpiryRefreshGrace) + } +} + +private extension ResetExpiryRefreshKey { + var identity: String { + let resetIdentity = String(Int(resetsAt.timeIntervalSince1970)) + return [provider.rawValue, configuredAccountID ?? accountID, limitID, resetIdentity] + .joined(separator: "|") + } +} + +private extension Sequence where Element == ResetExpiryRefreshKey { + var sortedByIdentity: [ResetExpiryRefreshKey] { + sorted { lhs, rhs in lhs.identity < rhs.identity } + } +} diff --git a/Sources/ContextPanelCore/SnapshotFreshness.swift b/Sources/ContextPanelCore/SnapshotFreshness.swift index 368a464..23d6f88 100644 --- a/Sources/ContextPanelCore/SnapshotFreshness.swift +++ b/Sources/ContextPanelCore/SnapshotFreshness.swift @@ -5,4 +5,6 @@ public enum SnapshotFreshness { public static let widgetMaximumAge: TimeInterval = appMaximumAge public static let refreshNeededAge: TimeInterval = 5 * 60 public static let widgetTimelineInterval: TimeInterval = 5 * 60 + public static let resetExpiryRefreshGrace: TimeInterval = 10 + public static let resetExpiryRetryDelay: TimeInterval = 30 } diff --git a/Sources/ContextPanelCore/SnapshotRefreshService.swift b/Sources/ContextPanelCore/SnapshotRefreshService.swift index bfb9815..0608454 100644 --- a/Sources/ContextPanelCore/SnapshotRefreshService.swift +++ b/Sources/ContextPanelCore/SnapshotRefreshService.swift @@ -176,15 +176,18 @@ private struct SnapshotRefreshLockMetadata: Codable { public struct SnapshotRefreshRunner: Sendable { public let service: SnapshotRefreshService public let stalenessPolicy: SnapshotStoreStalenessPolicy + public let resetExpiryRefreshStore: ResetExpiryRefreshStateStore? public let lock: SnapshotRefreshLock? public init( service: SnapshotRefreshService, stalenessPolicy: SnapshotStoreStalenessPolicy = SnapshotStoreStalenessPolicy(maximumAge: SnapshotFreshness.refreshNeededAge), + resetExpiryRefreshStore: ResetExpiryRefreshStateStore? = .appDefault(), lock: SnapshotRefreshLock? = .appDefault() ) { self.service = service self.stalenessPolicy = stalenessPolicy + self.resetExpiryRefreshStore = resetExpiryRefreshStore self.lock = lock } @@ -194,7 +197,7 @@ public struct SnapshotRefreshRunner: Sendable { public func refreshIfNeeded(now: Date = Date()) async throws -> SnapshotRefreshRunDecision { service.importConfiguredAuthFiles(now: now) - let current = service.loadCurrent(policy: stalenessPolicy, now: now) + let current = service.loadCurrent(policy: effectiveStalenessPolicy(), now: now) let promptCacheObservations = service.promptCacheObservations(now: now) if shouldSavePromptCacheOnly( current: current.snapshot, @@ -223,19 +226,73 @@ public struct SnapshotRefreshRunner: Sendable { } public func refresh(now: Date = Date()) async throws -> SnapshotRefreshRunDecision { + let previousSnapshot = service.loadCurrent().snapshot?.snapshot if let lock { guard let outcome = try await lock.withLock(now: now, { try await service.refresh(now: now) - }) else { return .skippedAlreadyRunning } - guard outcome.refreshResult.hasSnapshotPayload else { return .skippedNoReports } + }) else { + deferResetExpiryRefreshAfterLockContention(previousSnapshot: previousSnapshot, savedAt: now) + return .skippedAlreadyRunning + } + guard outcome.refreshResult.hasSnapshotPayload else { + recordResetExpiryRefreshAttemptWithoutSnapshot( + previousSnapshot: previousSnapshot, + savedAt: outcome.savedAt + ) + return .skippedNoReports + } + recordResetExpiryRefreshAttempt( + previousSnapshot: previousSnapshot, + refreshedSnapshot: service.loadCurrent().snapshot?.snapshot ?? outcome.refreshResult.snapshot, + refreshResult: outcome.refreshResult, + savedAt: outcome.savedAt + ) return .refreshed(outcome) } let outcome = try await service.refresh(now: now) - guard outcome.refreshResult.hasSnapshotPayload else { return .skippedNoReports } + guard outcome.refreshResult.hasSnapshotPayload else { + recordResetExpiryRefreshAttemptWithoutSnapshot(previousSnapshot: previousSnapshot, savedAt: outcome.savedAt) + return .skippedNoReports + } + recordResetExpiryRefreshAttempt( + previousSnapshot: previousSnapshot, + refreshedSnapshot: service.loadCurrent().snapshot?.snapshot ?? outcome.refreshResult.snapshot, + refreshResult: outcome.refreshResult, + savedAt: outcome.savedAt + ) return .refreshed(outcome) } + public func nextRefreshCheckDate(now: Date = Date()) -> Date? { + let current = service.loadCurrent().snapshot + return Self.nextRefreshCheckDate( + snapshot: current?.snapshot, + resetExpiryRefreshState: resetExpiryRefreshStore?.load() ?? .empty, + now: now + ) + } + + public static func nextRefreshCheckDate( + snapshot: UsageSnapshot?, + resetExpiryRefreshState: ResetExpiryRefreshState = .empty, + now: Date = Date() + ) -> Date? { + guard let snapshot else { return nil } + return resetExpiryRefreshState.nextRefreshCheckDate(for: snapshot, now: now) + } + + public static func nextRefreshCheckInterval( + normalInterval: TimeInterval, + nextCheckDate: Date?, + startedAt: Date, + finishedAt: Date + ) -> TimeInterval { + let normalWakeAt = startedAt.addingTimeInterval(normalInterval) + let wakeAt = [normalWakeAt, nextCheckDate].compactMap { $0 }.min() ?? normalWakeAt + return max(wakeAt.timeIntervalSince(finishedAt), 0) + } + public func saveMerged(refreshResult: ConnectorRefreshResult, savedAt: Date) async throws -> SnapshotRefreshRunDecision { try await saveMerged(refreshResult: refreshResult, savedAt: savedAt, retryFor: .zero) } @@ -271,6 +328,7 @@ public struct SnapshotRefreshRunner: Sendable { preservesUnreportedAccounts: Bool ) async throws -> SnapshotRefreshRunDecision { guard refreshResult.hasSnapshotPayload else { return .skippedNoReports } + let previousSnapshot = service.loadCurrent().snapshot?.snapshot if let lock { guard let outcome = try await lock.withLock(now: savedAt, { try service.saveMerged( @@ -279,14 +337,66 @@ public struct SnapshotRefreshRunner: Sendable { preservesUnreportedAccounts: preservesUnreportedAccounts ) }) else { return .skippedAlreadyRunning } + recordResetExpiryRefreshAttempt( + previousSnapshot: previousSnapshot, + refreshedSnapshot: service.loadCurrent().snapshot?.snapshot ?? refreshResult.snapshot, + refreshResult: refreshResult, + savedAt: savedAt + ) return .refreshed(outcome) } - return .refreshed(try service.saveMerged( + let outcome = try service.saveMerged( refreshResult: refreshResult, savedAt: savedAt, preservesUnreportedAccounts: preservesUnreportedAccounts - )) + ) + recordResetExpiryRefreshAttempt( + previousSnapshot: previousSnapshot, + refreshedSnapshot: service.loadCurrent().snapshot?.snapshot ?? refreshResult.snapshot, + refreshResult: refreshResult, + savedAt: savedAt + ) + return .refreshed(outcome) + } + + private func effectiveStalenessPolicy() -> SnapshotStoreStalenessPolicy { + SnapshotStoreStalenessPolicy( + maximumAge: stalenessPolicy.maximumAge, + resetExpiryRefreshState: resetExpiryRefreshStore?.load() + ) + } + + private func recordResetExpiryRefreshAttempt( + previousSnapshot: UsageSnapshot?, + refreshedSnapshot: UsageSnapshot, + refreshResult: ConnectorRefreshResult, + savedAt: Date + ) { + guard let resetExpiryRefreshStore else { return } + let attemptedAccounts = Set(refreshResult.reports.map(ResetExpiryRefreshAccountKey.init(report:))) + try? resetExpiryRefreshStore.update { state in + state.recordAttempt( + previousSnapshot: previousSnapshot, + refreshedSnapshot: refreshedSnapshot, + attemptedAccounts: attemptedAccounts.isEmpty ? nil : attemptedAccounts, + attemptedAt: savedAt + ) + } + } + + private func recordResetExpiryRefreshAttemptWithoutSnapshot(previousSnapshot: UsageSnapshot?, savedAt: Date) { + guard let resetExpiryRefreshStore else { return } + try? resetExpiryRefreshStore.update { state in + state.recordAttemptWithoutSnapshot(previousSnapshot: previousSnapshot, attemptedAt: savedAt) + } + } + + private func deferResetExpiryRefreshAfterLockContention(previousSnapshot: UsageSnapshot?, savedAt: Date) { + guard let resetExpiryRefreshStore else { return } + try? resetExpiryRefreshStore.update { state in + state.deferDueResets(previousSnapshot: previousSnapshot, attemptedAt: savedAt) + } } private func shouldSavePromptCacheOnly( @@ -388,6 +498,10 @@ public struct SnapshotRefreshService: Sendable { stores.primary.loadCurrent(policy: policy, now: now) } + public func loadCurrent() -> SnapshotStoreLoadResult { + stores.primary.loadCurrent() + } + public func promptCacheObservations(now: Date = Date()) -> [PromptCacheObservation] { promptCacheTelemetryMirror(bookmarkStore, promptCacheTelemetrySourceDirectories(now: now)) return promptCacheTelemetryReader(now) diff --git a/Sources/ContextPanelCore/SnapshotStore.swift b/Sources/ContextPanelCore/SnapshotStore.swift index 2159ced..315eccb 100644 --- a/Sources/ContextPanelCore/SnapshotStore.swift +++ b/Sources/ContextPanelCore/SnapshotStore.swift @@ -212,10 +212,15 @@ public struct SnapshotStoreQuery: Equatable, Sendable { public struct SnapshotStoreStalenessPolicy: Equatable, Sendable { public let maximumAge: TimeInterval + public let resetExpiryRefreshState: ResetExpiryRefreshState? - public init(maximumAge: TimeInterval = 15 * 60) { + public init( + maximumAge: TimeInterval = 15 * 60, + resetExpiryRefreshState: ResetExpiryRefreshState? = nil + ) { precondition(maximumAge >= 0, "maximumAge must not be negative") self.maximumAge = maximumAge + self.resetExpiryRefreshState = resetExpiryRefreshState } public func status(for storedSnapshot: StoredUsageSnapshot?, now: Date) -> UsageStatus { @@ -223,8 +228,22 @@ public struct SnapshotStoreStalenessPolicy: Equatable, Sendable { if now.timeIntervalSince(storedSnapshot.snapshot.generatedAt) > maximumAge { return .stale } + if hasDueResetExpiry(in: storedSnapshot.snapshot, now: now) { + return .stale + } return storedSnapshot.snapshot.aggregateStatus } + + private func hasDueResetExpiry(in snapshot: UsageSnapshot, now: Date) -> Bool { + let dueKeys = snapshot.resetRefreshDueKeys(now: now) + guard !dueKeys.isEmpty else { return false } + guard let resetExpiryRefreshState else { return true } + return dueKeys.contains { key in + guard let record = resetExpiryRefreshState.record(for: key) else { return true } + guard let nextRetryAt = record.nextRetryAt else { return false } + return nextRetryAt <= now + } + } } public struct JSONSnapshotStore: Sendable { diff --git a/Sources/ContextPanelRefreshAgent/ContextPanelRefreshAgent.swift b/Sources/ContextPanelRefreshAgent/ContextPanelRefreshAgent.swift index 2b44894..4d706dc 100644 --- a/Sources/ContextPanelRefreshAgent/ContextPanelRefreshAgent.swift +++ b/Sources/ContextPanelRefreshAgent/ContextPanelRefreshAgent.swift @@ -46,7 +46,6 @@ struct ContextPanelRefreshAgent { } while !Task.isCancelled { - let startedAt = ContinuousClock.now let wallStartedAt = Date() let settings = settingsStore.load() guard settings.isEnabled else { return } @@ -70,9 +69,13 @@ struct ContextPanelRefreshAgent { } do { - let elapsed = startedAt.duration(to: ContinuousClock.now) - let interval = Duration.seconds(settings.intervalSeconds) - try await Task.sleep(for: max(.zero, interval - elapsed)) + let sleepInterval = SnapshotRefreshRunner.nextRefreshCheckInterval( + normalInterval: TimeInterval(settings.intervalSeconds), + nextCheckDate: runner.nextRefreshCheckDate(now: Date()), + startedAt: wallStartedAt, + finishedAt: Date() + ) + try await Task.sleep(for: .seconds(sleepInterval)) } catch { return } diff --git a/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift index 4270943..0f88185 100644 --- a/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift +++ b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift @@ -1381,6 +1381,195 @@ import Testing #expect(primary.loadHistory().count == 1) } +@Test func snapshotStalenessPolicyMarksExpiredResetAsStaleAfterGrace() throws { + let savedAt = Date(timeIntervalSince1970: 1_000) + let resetAt = savedAt.addingTimeInterval(60) + let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [usageLimit(provider: .openAI, accountID: "openai", used: 100, savedAt: savedAt, resetsAt: resetAt)] + )) + let policy = SnapshotStoreStalenessPolicy(maximumAge: 5 * 60) + + #expect(policy.status(for: stored, now: resetAt.addingTimeInterval(9)) == .limited) + #expect(policy.status(for: stored, now: resetAt.addingTimeInterval(10)) == .stale) +} + +@Test func snapshotRefreshRunnerRefreshesWhenResetExpired() async throws { + let accountURL = try temporaryDirectory().appending(path: "accounts.json") + let primary = JSONSnapshotStore(rootDirectory: try temporaryDirectory()) + let resetStateStore = ResetExpiryRefreshStateStore(stateURL: try temporaryDirectory().appending(path: "reset-state.json")) + let savedAt = Date(timeIntervalSince1970: 1_000) + let resetAt = savedAt.addingTimeInterval(60) + try AccountConfigurationStore(configurationURL: accountURL).save(AccountConfigurationDocument( + updatedAt: savedAt, + accounts: [] + )) + try primary.save(StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [usageLimit(provider: .openAI, accountID: "openai", used: 100, savedAt: savedAt, resetsAt: resetAt)] + ))) + let service = SnapshotRefreshService( + accountStore: AccountConfigurationStore(configurationURL: accountURL), + stores: SnapshotRefreshStores(primary: primary), + promptCacheTelemetryReader: { _ in [] } + ) + let runner = SnapshotRefreshRunner( + service: service, + stalenessPolicy: SnapshotStoreStalenessPolicy(maximumAge: 5 * 60), + resetExpiryRefreshStore: resetStateStore, + lock: nil + ) + + let decision = try await runner.refreshIfNeeded(now: resetAt.addingTimeInterval(10)) + + #expect(decision == .skippedNoReports) + #expect(resetStateStore.load().records.count == 1) +} + +@Test func resetExpiryRefreshStateSuppressesImmediateRetryForSameExpiredWindow() throws { + let savedAt = Date(timeIntervalSince1970: 1_000) + let resetAt = savedAt.addingTimeInterval(60) + let staleSnapshot = UsageSnapshot(generatedAt: savedAt, limits: [ + usageLimit(provider: .openAI, accountID: "openai", used: 100, savedAt: savedAt, resetsAt: resetAt), + ]) + var state = ResetExpiryRefreshState() + + state.recordAttempt( + previousSnapshot: staleSnapshot, + refreshedSnapshot: staleSnapshot, + attemptedAt: resetAt.addingTimeInterval(10) + ) + + let suppressedPolicy = SnapshotStoreStalenessPolicy(maximumAge: 5 * 60, resetExpiryRefreshState: state) + let stored = StoredUsageSnapshot(savedAt: resetAt.addingTimeInterval(10), snapshot: staleSnapshot) + #expect(suppressedPolicy.status(for: stored, now: resetAt.addingTimeInterval(20)) == .limited) + #expect(suppressedPolicy.status(for: stored, now: resetAt.addingTimeInterval(40)) == .stale) +} + +@Test func resetExpiryRefreshStateSchedulesRetryInsteadOfPastResetDeadline() throws { + let savedAt = Date(timeIntervalSince1970: 1_000) + let resetAt = savedAt.addingTimeInterval(60) + let staleSnapshot = UsageSnapshot(generatedAt: savedAt, limits: [ + usageLimit(provider: .openAI, accountID: "openai", used: 100, savedAt: savedAt, resetsAt: resetAt), + ]) + var state = ResetExpiryRefreshState() + state.recordAttempt( + previousSnapshot: staleSnapshot, + refreshedSnapshot: staleSnapshot, + attemptedAt: resetAt.addingTimeInterval(10) + ) + + #expect(state.nextRefreshCheckDate(for: staleSnapshot, now: resetAt.addingTimeInterval(20)) == resetAt.addingTimeInterval(40)) +} + +@Test func resetExpiryRefreshStateDoesNotConsumeRetryForUnattemptedAccount() throws { + let savedAt = Date(timeIntervalSince1970: 1_000) + let resetAt = savedAt.addingTimeInterval(60) + let openAIStuck = usageLimit(provider: .openAI, accountID: "openai", used: 100, savedAt: savedAt, resetsAt: resetAt) + let googleStuck = usageLimit(provider: .google, accountID: "google", used: 100, savedAt: savedAt, resetsAt: resetAt) + let staleSnapshot = UsageSnapshot(generatedAt: savedAt, limits: [openAIStuck, googleStuck]) + var state = ResetExpiryRefreshState() + state.recordAttempt( + previousSnapshot: staleSnapshot, + refreshedSnapshot: staleSnapshot, + attemptedAccounts: [ResetExpiryRefreshAccountKey(provider: .openAI, accountID: "openai", configuredAccountID: nil)], + attemptedAt: resetAt.addingTimeInterval(10) + ) + + state.recordAttempt( + previousSnapshot: staleSnapshot, + refreshedSnapshot: staleSnapshot, + attemptedAccounts: [ResetExpiryRefreshAccountKey(provider: .google, accountID: "google", configuredAccountID: nil)], + attemptedAt: resetAt.addingTimeInterval(20) + ) + + let openAIKey = try #require(ResetExpiryRefreshKey(limit: openAIStuck)) + let googleKey = try #require(ResetExpiryRefreshKey(limit: googleStuck)) + let openAIRecord = try #require(state.record(for: openAIKey)) + let googleRecord = try #require(state.record(for: googleKey)) + #expect(openAIRecord.retryCount == 1) + #expect(openAIRecord.nextRetryAt == resetAt.addingTimeInterval(40)) + #expect(googleRecord.retryCount == 1) + #expect(googleRecord.nextRetryAt == resetAt.addingTimeInterval(50)) +} + +@Test func snapshotRefreshRunnerDefersResetRetryWhenRefreshLockIsHeld() async throws { + let accountURL = try temporaryDirectory().appending(path: "accounts.json") + let primary = JSONSnapshotStore(rootDirectory: try temporaryDirectory()) + let resetStateStore = ResetExpiryRefreshStateStore(stateURL: try temporaryDirectory().appending(path: "reset-state.json")) + let lockURL = try temporaryDirectory().appending(path: "refresh.lock") + try FileManager.default.createDirectory(at: lockURL.deletingLastPathComponent(), withIntermediateDirectories: true) + FileManager.default.createFile(atPath: lockURL.path, contents: Data()) + let savedAt = Date(timeIntervalSince1970: 1_000) + let resetAt = savedAt.addingTimeInterval(60) + try primary.save(StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [usageLimit(provider: .openAI, accountID: "openai", used: 100, savedAt: savedAt, resetsAt: resetAt)] + ))) + let service = SnapshotRefreshService( + accountStore: AccountConfigurationStore(configurationURL: accountURL), + stores: SnapshotRefreshStores(primary: primary), + promptCacheTelemetryReader: { _ in [] } + ) + let runner = SnapshotRefreshRunner( + service: service, + resetExpiryRefreshStore: resetStateStore, + lock: SnapshotRefreshLock(lockURL: lockURL, staleAfter: 60) + ) + let now = resetAt.addingTimeInterval(10) + + let decision = try await runner.refresh(now: now) + + #expect(decision == .skippedAlreadyRunning) + #expect(runner.nextRefreshCheckDate(now: now) == now.addingTimeInterval(30)) +} + +@Test func resetExpiryRefreshStateClearsWhenResetWindowAdvances() throws { + let savedAt = Date(timeIntervalSince1970: 1_000) + let resetAt = savedAt.addingTimeInterval(60) + let previous = UsageSnapshot(generatedAt: savedAt, limits: [ + usageLimit(provider: .openAI, accountID: "openai", used: 100, savedAt: savedAt, resetsAt: resetAt), + ]) + let refreshed = UsageSnapshot(generatedAt: resetAt.addingTimeInterval(10), limits: [ + usageLimit( + provider: .openAI, + accountID: "openai", + used: 0, + savedAt: resetAt.addingTimeInterval(10), + resetsAt: resetAt.addingTimeInterval(3_600) + ), + ]) + var state = ResetExpiryRefreshState(records: [ResetExpiryRefreshRecord( + key: try #require(ResetExpiryRefreshKey(limit: previous.limits[0])), + attemptedAt: resetAt.addingTimeInterval(10), + nextRetryAt: resetAt.addingTimeInterval(40), + retryCount: 1 + )]) + + state.recordAttempt( + previousSnapshot: previous, + refreshedSnapshot: refreshed, + attemptedAt: resetAt.addingTimeInterval(10) + ) + + #expect(state.records.isEmpty) +} + +@Test func snapshotRefreshRunnerNextRefreshCheckIntervalUsesResetDeadline() { + let startedAt = Date(timeIntervalSince1970: 1_000) + let finishedAt = startedAt.addingTimeInterval(2) + let resetCheckAt = startedAt.addingTimeInterval(20) + + let interval = SnapshotRefreshRunner.nextRefreshCheckInterval( + normalInterval: 5 * 60, + nextCheckDate: resetCheckAt, + startedAt: startedAt, + finishedAt: finishedAt + ) + + #expect(interval == 18) +} + @Test func snapshotRefreshRunnerSkipsWhenRefreshLockIsHeld() async throws { let accountURL = try temporaryDirectory().appending(path: "accounts.json") let primary = JSONSnapshotStore(rootDirectory: try temporaryDirectory()) @@ -1514,7 +1703,8 @@ private func usageLimit( accountID: String, configuredAccountID: String? = nil, used: Int, - savedAt: Date + savedAt: Date, + resetsAt: Date? = nil ) -> UsageLimit { UsageLimit( provider: provider, @@ -1525,7 +1715,7 @@ private func usageLimit( unit: .percent, used: used, limit: 100, - resetsAt: savedAt.addingTimeInterval(3_600), + resetsAt: resetsAt ?? savedAt.addingTimeInterval(3_600), lastUpdatedAt: savedAt, confidence: .observed ) diff --git a/scripts/context-panel-runtime-baseline.sh b/scripts/context-panel-runtime-baseline.sh index ec43802..629dc82 100755 --- a/scripts/context-panel-runtime-baseline.sh +++ b/scripts/context-panel-runtime-baseline.sh @@ -291,7 +291,7 @@ context_files() { for root in "${roots[@]}"; do [[ -e "$root" ]] || continue find "$root" -maxdepth 12 \ - \( -name accounts.json -o -name file-bookmarks.json -o -name current-snapshot.json -o -name history.json -o -name background-refresh-settings.json -o -name limit-warning-settings.json -o -name limit-warning-state.json -o -name limit-warning-pending-notifications.json -o -name webhook-settings.json -o -name webhook-delivery-state.json \) \ + \( -name accounts.json -o -name file-bookmarks.json -o -name current-snapshot.json -o -name history.json -o -name background-refresh-settings.json -o -name reset-expiry-refresh-state.json -o -name limit-warning-settings.json -o -name limit-warning-state.json -o -name limit-warning-pending-notifications.json -o -name webhook-settings.json -o -name webhook-delivery-state.json \) \ -print 2>/dev/null || true done }