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
41 changes: 18 additions & 23 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,32 @@ on:
- "*"

jobs:
lint:
name: Lint Code
runs-on: ubuntu-latest
steps:
- name: Repository checkout
uses: actions/checkout@v5
- name: Get swift version
run: swift --version
- name: Lint
run: make checklint
# lint:
# name: Lint Code
# runs-on: ubuntu-latest
# steps:
# - name: Repository checkout
# uses: actions/checkout@v5
# - name: Get swift version
# run: swift --version
# - name: Lint
# run: make checklint
test:
name: Test Xcode ${{ matrix.xcode }} - ${{ matrix.xcodebuildCommand }}
name: Test ${{ matrix.xcodebuildCommand }}
runs-on: "macos-26"
strategy:
fail-fast: true
matrix:
xcode:
- "26.1.1"
xcodebuildCommand:
- "-sdk iphonesimulator -destination 'platform=iOS Simulator,OS=26.1,name=iPhone 17'"
- "-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17'"
- "-sdk macosx -destination 'platform=macOS'"
- "-sdk xrsimulator -destination 'platform=visionOS Simulator,OS=26.1,name=Apple Vision Pro'"
- "-sdk appletvsimulator -destination 'platform=tvOS Simulator,OS=26.1,name=Apple TV 4K (3rd generation)'"
- "-sdk watchsimulator -destination 'platform=watchOS Simulator,OS=26.1,name=Apple Watch Series 11 (46mm)'"
- "-sdk xrsimulator -destination 'platform=visionOS Simulator,name=Apple Vision Pro'"
- "-sdk appletvsimulator -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)'"
- "-sdk watchsimulator -destination 'platform=watchOS Simulator,name=Apple Watch Series 11 (46mm)'"
steps:
- name: Repository checkout
uses: actions/checkout@v5
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ matrix.xcode }}

uses: actions/checkout@v6
- name: Select Xcode version
run: sudo xcode-select -s '/Applications/Xcode_26.4.1.app/Contents/Developer'
- name: Build and Test
run: xcodebuild test -scheme TelemetryDeck-Package ${{ matrix.xcodebuildCommand }}
55 changes: 40 additions & 15 deletions Sources/TelemetryDeck/Signals/SignalCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import Foundation
/// since all Signals automatically get a `receivedAt` property with a date, allowing the server to reorder them
/// correctly.
///
/// Currently the cache is only in-memory. This will probably change in the near future.
/// The cache persists signals to disk via `backupCache()` and automatically restores them in `init`.
/// Signals in excess of `cacheLimit` are dropped from the front (oldest first) to keep memory and disk usage bounded.
internal class SignalCache<T>: @unchecked Sendable where T: Codable {
internal var logHandler: LogHandler?

private var cachedSignals: [T] = []
private let maximumNumberOfSignalsToPopAtOnce = 100
private let cacheLimit: Int
private let cacheFileURL: URL?

let queue = DispatchQueue(label: "com.telemetrydeck.SignalCache", attributes: .concurrent)

Expand All @@ -26,13 +29,21 @@ internal class SignalCache<T>: @unchecked Sendable where T: Codable {
func push(_ signal: T) {
queue.sync(flags: .barrier) {
self.cachedSignals.append(signal)
self.trimToCacheLimitLocked()
}
}

/// Insert a number of Signals into the cache
func push(_ signals: [T]) {
queue.sync(flags: .barrier) {
self.cachedSignals.append(contentsOf: signals)
self.trimToCacheLimitLocked()
}
}

private func trimToCacheLimitLocked() {
if cachedSignals.count > cacheLimit {
cachedSignals.removeFirst(cachedSignals.count - cacheLimit)
}
}

Expand All @@ -50,6 +61,10 @@ internal class SignalCache<T>: @unchecked Sendable where T: Codable {
}

private func fileURL() -> URL {
if let cacheFileURL {
return cacheFileURL
}

// swiftlint:disable force_try
let cacheFolderURL = try! FileManager.default.url(
for: .cachesDirectory,
Expand All @@ -62,6 +77,25 @@ internal class SignalCache<T>: @unchecked Sendable where T: Codable {
return cacheFolderURL.appendingPathComponent("telemetrysignalcache")
}

/// Re-loads any signals previously persisted to disk and merges them with currently in-memory signals.
///
/// Trims the merged result to `cacheLimit`. Removes the on-disk file after a successful read.
func reloadFromDisk() {
queue.sync(flags: .barrier) {
loadFromDiskLocked()
}
}

private func loadFromDiskLocked() {
logHandler?.log(message: "Loading Telemetry cache from: \(fileURL())")
guard let data = try? Data(contentsOf: fileURL()) else { return }
try? FileManager.default.removeItem(at: fileURL())
guard let signals = try? JSONDecoder().decode([T].self, from: data) else { return }
logHandler?.log(message: "Loaded \(signals.count) signals")
cachedSignals.append(contentsOf: signals)
trimToCacheLimitLocked()
}

/// Save the entire signal cache to disk
func backupCache() {
queue.sync {
Expand All @@ -81,22 +115,13 @@ internal class SignalCache<T>: @unchecked Sendable where T: Codable {
}

/// Loads any previous signal cache from disk
init(logHandler: LogHandler?) {
init(logHandler: LogHandler?, cacheLimit: Int = 10_000, fileURL: URL? = nil) {
self.logHandler = logHandler
self.cacheLimit = cacheLimit
self.cacheFileURL = fileURL

queue.sync {
logHandler?.log(message: "Loading Telemetry cache from: \(fileURL())")

if let data = try? Data(contentsOf: fileURL()) {
// Loaded cache file, now delete it to stop it being loaded multiple times
try? FileManager.default.removeItem(at: fileURL())

// Decode the data into a new cache
if let signals = try? JSONDecoder().decode([T].self, from: data) {
logHandler?.log(message: "Loaded \(signals.count) signals")
self.cachedSignals = signals
}
}
queue.sync(flags: .barrier) {
loadFromDiskLocked()
}
}
}
Loading