fix(analytics): prevent database locks during suspension

This commit is contained in:
Rocky
2026-08-21 21:13:13 +08:00
parent 7bd77509b6
commit ac374631ae
11 changed files with 597 additions and 153 deletions
+167 -78
View File
@@ -12,40 +12,6 @@ import OSGKeyboardShared
import OSLog
import UIKit
actor AnalyticsUploadSignal: AnalyticsUploadTriggering {
typealias Action = @Sendable () async -> Void
private var action: Action?
private var isScheduled = false
private let debounce: Duration
init(debounce: Duration = .milliseconds(250)) {
self.debounce = debounce
}
func install(_ action: @escaping Action) {
self.action = action
}
func requestUpload() {
guard !isScheduled, let action else { return }
isScheduled = true
Task {
try? await Task.sleep(for: debounce)
guard !Task.isCancelled else {
uploadFinished()
return
}
await action()
uploadFinished()
}
}
private func uploadFinished() {
isScheduled = false
}
}
final class HostAnalyticsBearerBridge: AnalyticsBearerProviding, @unchecked Sendable {
static let shared = HostAnalyticsBearerBridge()
@@ -112,6 +78,12 @@ struct HostAnalyticsLogger: AnalyticsLogging {
@MainActor
final class AnalyticsHostService: ObservableObject {
private enum RequestedDatabaseAccess {
case uninitialized
case foreground
case suspended
}
static let shared = AnalyticsHostService()
static let backgroundTaskIdentifier = "com.osgkeyboard.ios.analytics-sync"
@@ -127,16 +99,24 @@ final class AnalyticsHostService: ObservableObject {
qos: .utility
)
private var isMonitoringNetwork = false
private var foregroundUploadTask: Task<Void, Never>?
private var requestedDatabaseAccess = RequestedDatabaseAccess.uninitialized
private var databaseTransitionTask: Task<Void, Never>?
private var databaseTransitionGeneration = 0
private var firstOpenAcquisitionChannel = AnalyticsAcquisitionChannel.unknown
private var pendingAuthenticatedAccountID: UUID?
private var hasPendingAccountDeletion = false
private var backgroundTaskIdentifier: UIBackgroundTaskIdentifier = .invalid
init(
bearerProvider: any AnalyticsBearerProviding = HostAnalyticsBearerBridge.shared,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork()
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
uploadPolicy: AnalyticsUploadPolicy = .mobileDefault
) {
let signal = AnalyticsUploadSignal()
let signal = AnalyticsUploadSignal(policy: uploadPolicy)
uploadSignal = signal
let environment = Self.environment
let runtime = AnalyticsRuntime.mainApp(
environment: Self.environment,
environment: environment,
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint),
network: network,
bearerProvider: bearerProvider,
@@ -148,53 +128,64 @@ final class AnalyticsHostService: ObservableObject {
Task {
await signal.install {
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4)
guard !Task.isCancelled else { return }
await runtime.uploadCoordinator.uploadAvailableEvents(
maximumBatches: uploadPolicy.maximumBatches
)
}
}
}
func prepare(firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel) {
self.firstOpenAcquisitionChannel = firstOpenAcquisitionChannel
startNetworkMonitoringIfNeeded()
Task {
await runtime.prepare(
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
)
let enabled = await runtime.isEnabled()
await MainActor.run {
self.isEnabled = enabled
}
client.recordSessionActivity()
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8)
}
}
func appDidBecomeActive() {
client.recordSessionActivity()
requestImmediateUpload(maximumBatches: 8)
requestForegroundDatabaseAccess()
}
func appWillResignActive() {
requestSuspendedDatabaseAccess()
}
func appDidEnterBackground() {
Self.scheduleBackgroundRefresh()
let identifier = UIApplication.shared.beginBackgroundTask(
withName: "analytics-sync"
)
requestSuspendedDatabaseAccess()
guard backgroundTaskIdentifier == .invalid else { return }
var identifier: UIBackgroundTaskIdentifier = .invalid
identifier = UIApplication.shared.beginBackgroundTask(
withName: "analytics-quiescence"
) { [weak self] in
Task { @MainActor in
self?.finishBackgroundTask(identifier)
}
}
guard identifier != .invalid else { return }
let task = Task {
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 2)
backgroundTaskIdentifier = identifier
let transitionTask = databaseTransitionTask
Task { [weak self] in
await transitionTask?.value
await MainActor.run {
UIApplication.shared.endBackgroundTask(identifier)
self?.finishBackgroundTask(identifier)
}
}
foregroundUploadTask = task
}
func observeAuthenticatedAccount(_ accountID: UUID) async {
_ = await runtime.observeAccount(stableIdentifier: accountID.uuidString)
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8)
pendingAuthenticatedAccountID = accountID
let transitionTask = databaseTransitionTask
await transitionTask?.value
await processDeferredAccountWork()
}
func handleAccountDeletion() async {
await runtime.handleAccountDeletion()
hasPendingAccountDeletion = true
let transitionTask = databaseTransitionTask
await transitionTask?.value
await processDeferredAccountWork()
}
func setEnabled(_ enabled: Bool) {
@@ -205,7 +196,7 @@ final class AnalyticsHostService: ObservableObject {
self.isEnabled = current
}
if current {
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4)
await uploadSignal.requestActivationUpload()
}
}
}
@@ -219,15 +210,6 @@ final class AnalyticsHostService: ObservableObject {
}
}
func requestImmediateUpload(maximumBatches: Int = 4) {
foregroundUploadTask?.cancel()
foregroundUploadTask = Task {
await runtime.uploadCoordinator.uploadAvailableEvents(
maximumBatches: maximumBatches
)
}
}
static func registerBackgroundTask() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: backgroundTaskIdentifier,
@@ -238,10 +220,12 @@ final class AnalyticsHostService: ObservableObject {
return
}
let operation = Task {
await AnalyticsHostService.shared.runtime.uploadCoordinator
.uploadAvailableEvents(maximumBatches: 4)
refreshTask.setTaskCompleted(success: !Task.isCancelled)
scheduleBackgroundRefresh()
let success = await AnalyticsHostService.shared
.performBackgroundRefresh()
refreshTask.setTaskCompleted(success: success)
if success {
scheduleBackgroundRefresh()
}
}
refreshTask.expirationHandler = {
operation.cancel()
@@ -260,13 +244,118 @@ final class AnalyticsHostService: ObservableObject {
isMonitoringNetwork = true
pathMonitor.pathUpdateHandler = { path in
guard path.status == .satisfied else { return }
Task { @MainActor in
AnalyticsHostService.shared.requestImmediateUpload(maximumBatches: 8)
Task {
await AnalyticsHostService.shared.uploadSignal
.requestActivationUpload()
}
}
pathMonitor.start(queue: monitorQueue)
}
private func requestForegroundDatabaseAccess() {
guard requestedDatabaseAccess != .foreground else { return }
requestedDatabaseAccess = .foreground
databaseTransitionGeneration += 1
let generation = databaseTransitionGeneration
let previous = databaseTransitionTask
let runtime = runtime
let signal = uploadSignal
let client = client
let acquisitionChannel = firstOpenAcquisitionChannel
let task = Task { [weak self] in
await previous?.value
await runtime.repository.resumeDatabaseAccess()
await runtime.prepare(
firstOpenAcquisitionChannel: acquisitionChannel
)
let enabled = await runtime.isEnabled()
await self?.processDeferredAccountWork()
await signal.setActive(true)
client.recordSessionActivity()
await signal.requestActivationUpload()
await MainActor.run {
guard let self else { return }
self.isEnabled = enabled
self.finishDatabaseTransition(generation: generation)
}
}
databaseTransitionTask = task
}
private func requestSuspendedDatabaseAccess() {
guard requestedDatabaseAccess != .suspended else { return }
requestedDatabaseAccess = .suspended
databaseTransitionGeneration += 1
let generation = databaseTransitionGeneration
let previous = databaseTransitionTask
let runtime = runtime
let signal = uploadSignal
let task = Task { [weak self] in
await previous?.value
await signal.pauseAndWait()
await runtime.repository.suspendDatabaseAccess()
await MainActor.run {
self?.finishDatabaseTransition(generation: generation)
}
}
databaseTransitionTask = task
}
private func processDeferredAccountWork() async {
guard requestedDatabaseAccess == .foreground else { return }
if hasPendingAccountDeletion {
await runtime.handleAccountDeletion()
hasPendingAccountDeletion = false
}
guard let accountID = pendingAuthenticatedAccountID else { return }
let observation = await runtime.observeAccount(
stableIdentifier: accountID.uuidString
)
guard requestedDatabaseAccess == .foreground else { return }
if case .unavailable = observation {
return
}
pendingAuthenticatedAccountID = nil
await uploadSignal.requestActivationUpload()
}
private func performBackgroundRefresh() async -> Bool {
let transitionTask = databaseTransitionTask
await transitionTask?.value
guard !Task.isCancelled else { return false }
await runtime.repository.resumeDatabaseAccess()
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 1)
let success = !Task.isCancelled
if requestedDatabaseAccess != .foreground {
await runtime.repository.suspendDatabaseAccess()
// A foreground transition may race the final close while this
// actor is re-entrant. Re-open if it won during the suspension.
if requestedDatabaseAccess == .foreground {
await runtime.repository.resumeDatabaseAccess()
}
}
return success
}
private func finishDatabaseTransition(generation: Int) {
guard databaseTransitionGeneration == generation else { return }
databaseTransitionTask = nil
}
private func finishBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) {
guard identifier != .invalid,
backgroundTaskIdentifier == identifier else {
return
}
UIApplication.shared.endBackgroundTask(identifier)
backgroundTaskIdentifier = .invalid
}
private static let endpoint = URL(
string: "https://account.osglab.com/v1/analytics/events"
)!
@@ -0,0 +1,156 @@
// AnalyticsUploadScheduling.swift
// OSGKeyboard · Main App
//
// Count- and time-based foreground upload scheduling. Recording remains
// fire-and-forget; networking is single-flight and stops when the app resigns.
import Foundation
import OSGKeyboardShared
struct AnalyticsUploadPolicy: Sendable {
static let mobileDefault = Self(
eventThreshold: 20,
flushInterval: .seconds(60),
activationDelay: .seconds(10),
maximumBatches: 2
)
let eventThreshold: Int
let flushInterval: Duration
let activationDelay: Duration
let maximumBatches: Int
init(
eventThreshold: Int,
flushInterval: Duration,
activationDelay: Duration,
maximumBatches: Int
) {
self.eventThreshold = max(1, eventThreshold)
self.flushInterval = flushInterval
self.activationDelay = activationDelay
self.maximumBatches = max(1, maximumBatches)
}
}
actor AnalyticsUploadSignal: AnalyticsUploadTriggering {
typealias Action = @Sendable () async -> Void
private let policy: AnalyticsUploadPolicy
private var action: Action?
private var isActive = false
private var pendingSignals = 0
private var timerTask: Task<Void, Never>?
private var uploadTask: Task<Void, Never>?
init(policy: AnalyticsUploadPolicy = .mobileDefault) {
self.policy = policy
}
func install(_ action: @escaping Action) {
self.action = action
if isActive, pendingSignals > 0, uploadTask == nil {
schedule(after: policy.activationDelay, replacingTimer: true)
}
}
func requestUpload() {
pendingSignals = min(Int.max, pendingSignals + 1)
guard isActive, uploadTask == nil else { return }
if pendingSignals >= policy.eventThreshold {
schedule(after: .zero, replacingTimer: true)
} else {
schedule(after: policy.flushInterval)
}
}
/// Starts one bounded backlog drain after the foreground has settled.
func requestActivationUpload() {
pendingSignals = max(1, pendingSignals)
guard isActive, uploadTask == nil else { return }
schedule(after: policy.activationDelay, replacingTimer: true)
}
func setActive(_ active: Bool) async {
guard active != isActive else {
if active, pendingSignals > 0, uploadTask == nil {
schedule(after: policy.activationDelay)
}
return
}
isActive = active
if active {
if pendingSignals > 0 {
schedule(after: policy.activationDelay, replacingTimer: true)
}
} else {
await cancelAndWait()
}
}
/// Cancels timers and networking, then waits until the upload action has
/// unwound. Callers can keep a short UIKit background assertion while this
/// returns so no SQLite transaction survives process suspension.
func pauseAndWait() async {
isActive = false
await cancelAndWait()
}
private func schedule(
after delay: Duration,
replacingTimer: Bool = false
) {
guard isActive, action != nil, uploadTask == nil else { return }
if replacingTimer {
timerTask?.cancel()
timerTask = nil
}
guard timerTask == nil else { return }
timerTask = Task { [weak self] in
do {
try await Task.sleep(for: delay)
} catch {
return
}
await self?.timerFired()
}
}
private func timerFired() {
timerTask = nil
guard isActive,
uploadTask == nil,
pendingSignals > 0,
let action else {
return
}
pendingSignals = 0
uploadTask = Task { [weak self] in
await action()
await self?.uploadFinished()
}
}
private func uploadFinished() {
uploadTask = nil
guard isActive, pendingSignals > 0 else { return }
let delay: Duration = pendingSignals >= policy.eventThreshold
? .zero
: policy.flushInterval
schedule(after: delay, replacingTimer: true)
}
private func cancelAndWait() async {
timerTask?.cancel()
timerTask = nil
let task = uploadTask
task?.cancel()
await task?.value
uploadTask = nil
}
}
+5
View File
@@ -84,6 +84,9 @@ struct MainAppRoot: View {
analytics.prepare(
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
)
if scenePhase == .active {
analytics.appDidBecomeActive()
}
OSGDiag.log(
"MainAppRoot.onAppear scene=\(String(describing: scenePhase)) "
+ "onboarding=\(config.hasCompletedOnboarding) \(OSGDiag.memoryTag())",
@@ -152,6 +155,8 @@ struct MainAppRoot: View {
flowManager.handleScenePhase(phase)
if phase == .active {
analytics.appDidBecomeActive()
} else if phase == .inactive {
analytics.appWillResignActive()
} else if phase == .background {
analytics.appDidEnterBackground()
}