Skip to content

Commit 957707b

Browse files
authored
[Collections] Use persistent cache for GitHub package metadata (#3441)
* [Collections] Use persistent cache for GitHub package metadata Motivation: Currently we use transient in-memory cache for storing GitHub package metadata. Modifications: - Add generic `SQLiteBackedCache` in Basics - Change `ManifestLoader` to use `SQLiteBackedCache` - Change `GitHubPackageMetadataProvider` to use `SQLiteBackedCache` - Adjust tests * Undo swiftformat changes * Fix CMake build * Rename name to tableName * Document 'tableName'
1 parent c0ab2f9 commit 957707b

File tree

11 files changed

+745
-571
lines changed

11 files changed

+745
-571
lines changed

Sources/Basics/CMakeLists.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This source file is part of the Swift.org open source project
22
#
3-
# Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors
3+
# Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
44
# Licensed under Apache License v2.0 with Runtime Library Exception
55
#
66
# See http://swift.org/LICENSE.txt for license information
@@ -17,7 +17,8 @@ add_library(Basics
1717
HTTPClient.swift
1818
JSON+Extensions.swift
1919
Sandbox.swift
20-
SwiftVersion.swift)
20+
SwiftVersion.swift
21+
SQLiteBackedCache.swift)
2122
target_link_libraries(Basics PUBLIC
2223
TSCBasic
2324
TSCUtility)
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
13+
import TSCBasic
14+
import TSCUtility
15+
16+
/// SQLite backed persistent cache.
17+
public final class SQLiteBackedCache<Value: Codable>: Closable {
18+
public typealias Key = String
19+
20+
public let tableName: String
21+
public let fileSystem: TSCBasic.FileSystem
22+
public let location: SQLite.Location
23+
public let configuration: SQLiteBackedCacheConfiguration
24+
25+
private var state = State.idle
26+
private let stateLock = Lock()
27+
28+
private let diagnosticsEngine: DiagnosticsEngine?
29+
private let jsonEncoder: JSONEncoder
30+
private let jsonDecoder: JSONDecoder
31+
32+
/// Creates a SQLite-backed cache.
33+
///
34+
/// - Parameters:
35+
/// - tableName: The SQLite table name. Must follow SQLite naming rules (e.g., no spaces).
36+
/// - location: SQLite.Location
37+
/// - configuration: Optional. Configuration for the cache.
38+
/// - diagnosticsEngine: DiagnosticsEngine
39+
public init(tableName: String, location: SQLite.Location, configuration: SQLiteBackedCacheConfiguration = .init(), diagnosticsEngine: DiagnosticsEngine? = nil) {
40+
self.tableName = tableName
41+
self.location = location
42+
switch self.location {
43+
case .path, .temporary:
44+
self.fileSystem = localFileSystem
45+
case .memory:
46+
self.fileSystem = InMemoryFileSystem()
47+
}
48+
self.configuration = configuration
49+
self.diagnosticsEngine = diagnosticsEngine
50+
self.jsonEncoder = JSONEncoder.makeWithDefaults()
51+
self.jsonDecoder = JSONDecoder.makeWithDefaults()
52+
}
53+
54+
/// Creates a SQLite-backed cache.
55+
///
56+
/// - Parameters:
57+
/// - tableName: The SQLite table name. Must follow SQLite naming rules (e.g., no spaces).
58+
/// - path: The path of the SQLite database.
59+
/// - configuration: Optional. Configuration for the cache.
60+
/// - diagnosticsEngine: DiagnosticsEngine
61+
public convenience init(tableName: String, path: AbsolutePath, configuration: SQLiteBackedCacheConfiguration = .init(), diagnosticsEngine: DiagnosticsEngine? = nil) {
62+
self.init(tableName: tableName, location: .path(path), configuration: configuration, diagnosticsEngine: diagnosticsEngine)
63+
}
64+
65+
deinit {
66+
// TODO: we could wrap the failure here with diagnostics if it wasn't optional throughout
67+
try? self.withStateLock {
68+
if case .connected(let db) = self.state {
69+
assertionFailure("db should be closed")
70+
try db.close()
71+
}
72+
}
73+
}
74+
75+
public func close() throws {
76+
try self.withStateLock {
77+
if case .connected(let db) = self.state {
78+
try db.close()
79+
}
80+
self.state = .disconnected
81+
}
82+
}
83+
84+
public func put(key: Key, value: Value, replace: Bool = false) throws {
85+
do {
86+
let query = "INSERT OR \(replace ? "REPLACE" : "IGNORE") INTO \(self.tableName) VALUES (?, ?);"
87+
try self.executeStatement(query) { statement -> Void in
88+
let data = try self.jsonEncoder.encode(value)
89+
let bindings: [SQLite.SQLiteValue] = [
90+
.string(key),
91+
.blob(data),
92+
]
93+
try statement.bind(bindings)
94+
try statement.step()
95+
}
96+
} catch (let error as SQLite.Errors) where error == .databaseFull {
97+
if !self.configuration.truncateWhenFull {
98+
throw error
99+
}
100+
self.diagnosticsEngine?.emit(.warning("truncating \(self.tableName) cache database since it reached max size of \(self.configuration.maxSizeInBytes ?? 0) bytes"))
101+
try self.executeStatement("DELETE FROM \(self.tableName);") { statement -> Void in
102+
try statement.step()
103+
}
104+
try self.put(key: key, value: value, replace: replace)
105+
} catch {
106+
throw error
107+
}
108+
}
109+
110+
public func get(key: Key) throws -> Value? {
111+
let query = "SELECT value FROM \(self.tableName) WHERE key = ? LIMIT 1;"
112+
return try self.executeStatement(query) { statement -> Value? in
113+
try statement.bind([.string(key)])
114+
let data = try statement.step()?.blob(at: 0)
115+
return try data.flatMap {
116+
try self.jsonDecoder.decode(Value.self, from: $0)
117+
}
118+
}
119+
}
120+
121+
public func remove(key: Key) throws {
122+
let query = "DELETE FROM \(self.tableName) WHERE key = ? LIMIT 1;"
123+
try self.executeStatement(query) { statement in
124+
try statement.bind([.string(key)])
125+
try statement.step()
126+
}
127+
}
128+
129+
private func executeStatement<T>(_ query: String, _ body: (SQLite.PreparedStatement) throws -> T) throws -> T {
130+
try self.withDB { db in
131+
let result: Result<T, Error>
132+
let statement = try db.prepare(query: query)
133+
do {
134+
result = .success(try body(statement))
135+
} catch {
136+
result = .failure(error)
137+
}
138+
try statement.finalize()
139+
switch result {
140+
case .failure(let error):
141+
throw error
142+
case .success(let value):
143+
return value
144+
}
145+
}
146+
}
147+
148+
private func withDB<T>(_ body: (SQLite) throws -> T) throws -> T {
149+
let createDB = { () throws -> SQLite in
150+
let db = try SQLite(location: self.location, configuration: self.configuration.underlying)
151+
try self.createSchemaIfNecessary(db: db)
152+
return db
153+
}
154+
155+
let db = try self.withStateLock { () -> SQLite in
156+
let db: SQLite
157+
switch (self.location, self.state) {
158+
case (.path(let path), .connected(let database)):
159+
if self.fileSystem.exists(path) {
160+
db = database
161+
} else {
162+
try database.close()
163+
try self.fileSystem.createDirectory(path.parentDirectory, recursive: true)
164+
db = try createDB()
165+
}
166+
case (.path(let path), _):
167+
if !self.fileSystem.exists(path) {
168+
try self.fileSystem.createDirectory(path.parentDirectory, recursive: true)
169+
}
170+
db = try createDB()
171+
case (_, .connected(let database)):
172+
db = database
173+
case (_, _):
174+
db = try createDB()
175+
}
176+
self.state = .connected(db)
177+
return db
178+
}
179+
180+
// FIXME: workaround linux sqlite concurrency issues causing CI failures
181+
#if os(Linux)
182+
return try self.withStateLock {
183+
return try body(db)
184+
}
185+
#else
186+
return try body(db)
187+
#endif
188+
}
189+
190+
private func createSchemaIfNecessary(db: SQLite) throws {
191+
let table = """
192+
CREATE TABLE IF NOT EXISTS \(self.tableName) (
193+
key STRING PRIMARY KEY NOT NULL,
194+
value BLOB NOT NULL
195+
);
196+
"""
197+
198+
try db.exec(query: table)
199+
try db.exec(query: "PRAGMA journal_mode=WAL;")
200+
}
201+
202+
private func withStateLock<T>(_ body: () throws -> T) throws -> T {
203+
switch self.location {
204+
case .path(let path):
205+
if !self.fileSystem.exists(path.parentDirectory) {
206+
try self.fileSystem.createDirectory(path.parentDirectory)
207+
}
208+
return try self.fileSystem.withLock(on: path, type: .exclusive, body)
209+
case .memory, .temporary:
210+
return try self.stateLock.withLock(body)
211+
}
212+
}
213+
214+
private enum State {
215+
case idle
216+
case connected(SQLite)
217+
case disconnected
218+
}
219+
}
220+
221+
public struct SQLiteBackedCacheConfiguration {
222+
public var truncateWhenFull: Bool
223+
224+
fileprivate var underlying: SQLite.Configuration
225+
226+
public init() {
227+
self.underlying = .init()
228+
self.truncateWhenFull = true
229+
self.maxSizeInMegabytes = 100
230+
// see https://www.sqlite.org/c3ref/busy_timeout.html
231+
self.busyTimeoutMilliseconds = 1000
232+
}
233+
234+
public var maxSizeInMegabytes: Int? {
235+
get {
236+
self.underlying.maxSizeInMegabytes
237+
}
238+
set {
239+
self.underlying.maxSizeInMegabytes = newValue
240+
}
241+
}
242+
243+
public var maxSizeInBytes: Int? {
244+
get {
245+
self.underlying.maxSizeInBytes
246+
}
247+
set {
248+
self.underlying.maxSizeInBytes = newValue
249+
}
250+
}
251+
252+
public var busyTimeoutMilliseconds: Int32 {
253+
get {
254+
self.underlying.busyTimeoutMilliseconds
255+
}
256+
set {
257+
self.underlying.busyTimeoutMilliseconds = newValue
258+
}
259+
}
260+
}

Sources/PackageCollections/PackageCollections.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public struct PackageCollections: PackageCollectionsProtocol {
6565
if self.storageContainer.owned {
6666
try self.storageContainer.storage.close()
6767
}
68+
try self.metadataProvider.close()
6869
}
6970

7071
// MARK: - Collections

0 commit comments

Comments
 (0)