Skip to content

Commit e8b552b

Browse files
authored
implement naive search (#3044)
motivation: continue to implement package collections business logic changes: * implement naive in-memory search for packages and targets * performance work on "list" by making decoding in parallel and in-memory cache (will be improved) * enbale performance tests
1 parent 83f20b2 commit e8b552b

File tree

7 files changed

+251
-65
lines changed

7 files changed

+251
-65
lines changed

Sources/PackageCollections/Model/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ extension PackageCollectionsModel.Package {
121121

122122
extension PackageCollectionsModel {
123123
/// A representation of package target
124-
public struct PackageTarget: Equatable, Codable {
124+
public struct PackageTarget: Equatable, Hashable, Codable {
125125
/// The target name
126126
public let name: String
127127

Sources/PackageCollections/Model/Search.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ extension PackageCollectionsModel {
3434
/// A representation of target in search result
3535
public struct TargetSearchResult {
3636
/// Result items of the search
37-
public let items: TargetListResult
37+
public let items: [TargetListItem]
3838
}
3939
}

Sources/PackageCollections/Storage/PackageCollectionsProfileStorage.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ struct FilePackageCollectionsProfileStorage: PackageCollectionsProfileStorage {
8585
let fileSystem: FileSystem
8686
let path: AbsolutePath
8787

88-
private let jsonEncoder: JSONEncoder
89-
private let jsonDecoder: JSONDecoder
88+
private let encoder: JSONEncoder
89+
private let decoder: JSONDecoder
9090

9191
private let queue = DispatchQueue(label: "org.swift.swiftpm.FilePackageCollectionsProfileStorage")
9292

@@ -96,13 +96,13 @@ struct FilePackageCollectionsProfileStorage: PackageCollectionsProfileStorage {
9696
let name = "collections"
9797
self.path = path ?? fileSystem.dotSwiftPM.appending(component: "\(name).json")
9898

99-
self.jsonEncoder = JSONEncoder()
100-
self.jsonEncoder.outputFormatting = .prettyPrinted
99+
self.encoder = JSONEncoder()
100+
self.encoder.outputFormatting = .prettyPrinted
101101
if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) {
102-
self.jsonEncoder.outputFormatting.insert(.sortedKeys)
103-
self.jsonEncoder.outputFormatting.insert(.withoutEscapingSlashes)
102+
self.encoder.outputFormatting.insert(.sortedKeys)
103+
self.encoder.outputFormatting.insert(.withoutEscapingSlashes)
104104
}
105-
self.jsonDecoder = JSONDecoder()
105+
self.decoder = JSONDecoder()
106106
}
107107

108108
func listProfiles(callback: @escaping (Result<[PackageCollectionsModel.Profile], Error>) -> Void) {
@@ -224,7 +224,7 @@ struct FilePackageCollectionsProfileStorage: PackageCollectionsProfileStorage {
224224
guard buffer.count > 0 else {
225225
return .init()
226226
}
227-
let container = try jsonDecoder.decode(Model.Container.self, from: Data(buffer))
227+
let container = try decoder.decode(Model.Container.self, from: Data(buffer))
228228
return try container.profiles()
229229
}
230230

@@ -233,7 +233,7 @@ struct FilePackageCollectionsProfileStorage: PackageCollectionsProfileStorage {
233233
try self.fileSystem.createDirectory(self.path.parentDirectory, recursive: true)
234234
}
235235
let container = Model.Container(profiles)
236-
let buffer = try jsonEncoder.encode(container)
236+
let buffer = try encoder.encode(container)
237237
try self.fileSystem.writeFileContents(self.path, bytes: ByteString(buffer))
238238
}
239239

Sources/PackageCollections/Storage/PackageCollectionsStorage.swift

Lines changed: 180 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,18 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
9494
let fileSystem: FileSystem
9595
let location: SQLite.Location
9696

97-
private let jsonEncoder: JSONEncoder
98-
private let jsonDecoder: JSONDecoder
97+
private let encoder: JSONEncoder
98+
private let decoder: JSONDecoder
9999

100100
// for concurrent for DB access
101101
private let queue = DispatchQueue(label: "org.swift.swiftpm.SQLitePackageCollectionsStorage", attributes: .concurrent)
102102

103103
private var state = State.idle
104104
private let stateLock = Lock()
105105

106+
private var cache = [PackageCollectionsModel.CollectionIdentifier: PackageCollectionsModel.Collection]()
107+
private let cacheLock = Lock()
108+
106109
init(location: SQLite.Location? = nil) {
107110
self.location = location ?? .path(localFileSystem.swiftPMCacheDirectory.appending(components: "package-collection.db"))
108111
switch self.location {
@@ -111,8 +114,8 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
111114
case .memory:
112115
self.fileSystem = InMemoryFileSystem()
113116
}
114-
self.jsonEncoder = JSONEncoder()
115-
self.jsonDecoder = JSONDecoder()
117+
self.encoder = JSONEncoder()
118+
self.decoder = JSONDecoder()
116119
}
117120

118121
convenience init(path: AbsolutePath) {
@@ -138,9 +141,10 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
138141
callback: @escaping (Result<PackageCollectionsModel.Collection, Error>) -> Void) {
139142
self.queue.async {
140143
do {
144+
// write to db
141145
let query = "INSERT OR IGNORE INTO PACKAGES_COLLECTIONS VALUES (?, ?);"
142146
try self.executeStatement(query) { statement -> Void in
143-
let data = try self.jsonEncoder.encode(collection)
147+
let data = try self.encoder.encode(collection)
144148

145149
let bindings: [SQLite.SQLiteValue] = [
146150
.string(collection.identifier.databaseKey()),
@@ -149,6 +153,10 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
149153
try statement.bind(bindings)
150154
try statement.step()
151155
}
156+
// write to cache
157+
self.cacheLock.withLock {
158+
self.cache[collection.identifier] = collection
159+
}
152160
callback(.success(collection))
153161
} catch {
154162
callback(.failure(error))
@@ -160,6 +168,7 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
160168
callback: @escaping (Result<Void, Error>) -> Void) {
161169
self.queue.async {
162170
do {
171+
// write to db
163172
let query = "DELETE FROM PACKAGES_COLLECTIONS WHERE key == ?;"
164173
try self.executeStatement(query) { statement -> Void in
165174
let bindings: [SQLite.SQLiteValue] = [
@@ -168,6 +177,10 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
168177
try statement.bind(bindings)
169178
try statement.step()
170179
}
180+
// write to cache
181+
self.cacheLock.withLock {
182+
self.cache[identifier] = nil
183+
}
171184
callback(.success(()))
172185
} catch {
173186
callback(.failure(error))
@@ -177,6 +190,12 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
177190

178191
func get(identifier: PackageCollectionsModel.CollectionIdentifier,
179192
callback: @escaping (Result<PackageCollectionsModel.Collection, Error>) -> Void) {
193+
// try read to cache
194+
if let collection = (self.cacheLock.withLock { self.cache[identifier] }) {
195+
return callback(.success(collection))
196+
}
197+
198+
// go to db if not found
180199
self.queue.async {
181200
do {
182201
let query = "SELECT value FROM PACKAGES_COLLECTIONS WHERE key == ? LIMIT 1;"
@@ -188,7 +207,7 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
188207
throw NotFoundError("\(identifier)")
189208
}
190209

191-
let collection = try self.jsonDecoder.decode(PackageCollectionsModel.Collection.self, from: data)
210+
let collection = try self.decoder.decode(PackageCollectionsModel.Collection.self, from: data)
192211
return collection
193212
}
194213
callback(.success(collection))
@@ -200,11 +219,21 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
200219

201220
func list(identifiers: [PackageCollectionsModel.CollectionIdentifier]? = nil,
202221
callback: @escaping (Result<[PackageCollectionsModel.Collection], Error>) -> Void) {
222+
// try read to cache
223+
let cached = self.cacheLock.withLock {
224+
identifiers?.compactMap { identifier in
225+
self.cache[identifier]
226+
}
227+
}
228+
if let cached = cached, cached.count > 0, cached.count == identifiers?.count {
229+
return callback(.success(cached))
230+
}
231+
232+
// go to db if not found
203233
self.queue.async {
204234
do {
205235
var blobs = [Data]()
206236
if let identifiers = identifiers {
207-
// TODO: consider running these in parallel
208237
var index = 0
209238
while index < identifiers.count {
210239
let slice = identifiers[index ..< min(index + Self.batchSize, identifiers.count)]
@@ -226,25 +255,86 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
226255
}
227256
}
228257

229-
// TODO: consider some diagnostics / warning for invalid data
230-
let collections = blobs.compactMap { data -> PackageCollectionsModel.Collection? in
231-
try? self.jsonDecoder.decode(PackageCollectionsModel.Collection.self, from: data)
258+
// decoding is a performance bottleneck (10+s for 1000 collections)
259+
// workaround is to decode in parallel if list is large enough to justify it
260+
var collections: [PackageCollectionsModel.Collection]
261+
if blobs.count < 50 {
262+
// TODO: consider some diagnostics / warning for invalid data
263+
collections = blobs.compactMap { data -> PackageCollectionsModel.Collection? in
264+
try? self.decoder.decode(PackageCollectionsModel.Collection.self, from: data)
265+
}
266+
} else {
267+
let lock = Lock()
268+
let sync = DispatchGroup()
269+
collections = [PackageCollectionsModel.Collection]()
270+
blobs.forEach { data in
271+
sync.enter()
272+
self.queue.async {
273+
defer { sync.leave() }
274+
if let collection = try? self.decoder.decode(PackageCollectionsModel.Collection.self, from: data) {
275+
lock.withLock {
276+
collections.append(collection)
277+
}
278+
}
279+
}
280+
}
281+
sync.wait()
232282
}
283+
233284
callback(.success(collections))
234285
} catch {
235286
callback(.failure(error))
236287
}
237288
}
238289
}
239290

240-
// FIXME: implement this
291+
// TODO: this is PoC for search, need a more performant version of this
241292
func searchPackages(identifiers: [PackageCollectionsModel.CollectionIdentifier]? = nil,
242293
query: String,
243294
callback: @escaping (Result<PackageCollectionsModel.PackageSearchResult, Error>) -> Void) {
244-
fatalError("not implemented")
295+
let queryString = query.lowercased()
296+
297+
self.list(identifiers: identifiers) { result in
298+
switch result {
299+
case .failure(let error):
300+
callback(.failure(error))
301+
case .success(let collections):
302+
let collectionsPackages = collections.reduce([PackageCollectionsModel.CollectionIdentifier: [PackageCollectionsModel.Collection.Package]]()) { partial, collection in
303+
var map = partial
304+
map[collection.identifier] = collection.packages.filter { package in
305+
if package.repository.url.lowercased().contains(queryString) { return true }
306+
if let summary = package.summary, summary.lowercased().contains(queryString) { return true }
307+
return package.versions.contains(where: { version in
308+
if version.packageName.lowercased().contains(queryString) { return true }
309+
if version.products.contains(where: { $0.name.lowercased().contains(queryString) }) { return true }
310+
return version.targets.contains(where: { $0.name.lowercased().contains(queryString) })
311+
})
312+
}
313+
return map
314+
}
315+
316+
// compose result :p
317+
318+
var packageCollections = [PackageReference: (package: PackageCollectionsModel.Collection.Package, collections: Set<PackageCollectionsModel.CollectionIdentifier>)]()
319+
collectionsPackages.forEach { collectionIdentifier, packages in
320+
packages.forEach { package in
321+
// Avoid copy-on-write: remove entry from dictionary before mutating
322+
var entry = packageCollections.removeValue(forKey: package.reference) ?? (package, .init())
323+
entry.collections.insert(collectionIdentifier)
324+
packageCollections[package.reference] = entry
325+
}
326+
}
327+
328+
let result = PackageCollectionsModel.PackageSearchResult(items: packageCollections.map { entry in
329+
.init(package: entry.value.package, collections: Array(entry.value.collections))
330+
})
331+
332+
callback(.success(result))
333+
}
334+
}
245335
}
246336

247-
// FIXME: this is PoC for search, need a more performant version of this
337+
// TODO: this is PoC for search, need a more performant version of this
248338
func findPackage(identifier: PackageReference.PackageIdentity,
249339
collectionIdentifiers: [PackageCollectionsModel.CollectionIdentifier]?,
250340
callback: @escaping (Result<PackageCollectionsModel.PackageSearchResult.Item, Error>) -> Void) {
@@ -269,14 +359,89 @@ final class SQLitePackageCollectionsStorage: PackageCollectionsStorage, Closable
269359
}
270360
}
271361

272-
// FIXME: implement this
362+
// TODO: this is PoC for search, need a more performant version of this
273363
func searchTargets(identifiers: [PackageCollectionsModel.CollectionIdentifier]? = nil,
274364
query: String,
275365
type: PackageCollectionsModel.TargetSearchType,
276366
callback: @escaping (Result<PackageCollectionsModel.TargetSearchResult, Error>) -> Void) {
277-
fatalError("not implemented")
367+
let query = query.lowercased()
368+
369+
self.list(identifiers: identifiers) { result in
370+
switch result {
371+
case .failure(let error):
372+
callback(.failure(error))
373+
case .success(let collections):
374+
let collectionsPackages = collections.reduce([PackageCollectionsModel.CollectionIdentifier: [(target: PackageCollectionsModel.PackageTarget, package: PackageCollectionsModel.Collection.Package)]]()) { partial, collection in
375+
var map = partial
376+
collection.packages.forEach { package in
377+
package.versions.forEach { version in
378+
version.targets.forEach { target in
379+
let match: Bool
380+
switch type {
381+
case .exactMatch:
382+
match = target.name.lowercased() == query
383+
case .prefix:
384+
match = target.name.lowercased().hasPrefix(query)
385+
}
386+
if match {
387+
// Avoid copy-on-write: remove entry from dictionary before mutating
388+
var entry = map.removeValue(forKey: collection.identifier) ?? .init()
389+
entry.append((target, package))
390+
map[collection.identifier] = entry
391+
}
392+
}
393+
}
394+
}
395+
return map
396+
}
397+
398+
// compose result :p
399+
400+
var packageCollections = [PackageReference: (package: PackageCollectionsModel.Collection.Package, collections: Set<PackageCollectionsModel.CollectionIdentifier>)]()
401+
var targetsPackages = [PackageCollectionsModel.PackageTarget: Set<PackageReference>]()
402+
403+
collectionsPackages.forEach { collectionIdentifier, packagesAndTargets in
404+
packagesAndTargets.forEach { item in
405+
// Avoid copy-on-write: remove entry from dictionary before mutating
406+
var packageCollectionsEntry = packageCollections.removeValue(forKey: item.package.reference) ?? (item.package, .init())
407+
packageCollectionsEntry.collections.insert(collectionIdentifier)
408+
packageCollections[item.package.reference] = packageCollectionsEntry
409+
410+
// Avoid copy-on-write: remove entry from dictionary before mutating
411+
var targetsPackagesEntry = targetsPackages.removeValue(forKey: item.target) ?? .init()
412+
targetsPackagesEntry.insert(item.package.reference)
413+
targetsPackages[item.target] = targetsPackagesEntry
414+
}
415+
}
416+
417+
let result = PackageCollectionsModel.TargetSearchResult(items: targetsPackages.map { target, packages in
418+
let targetsPackages = packages
419+
.compactMap { packageCollections[$0] }
420+
.map { pair -> PackageCollectionsModel.TargetListItem.Package in
421+
let versions = pair.package.versions.map { PackageCollectionsModel.TargetListItem.Package.Version(version: $0.version, packageName: $0.packageName) }
422+
return PackageCollectionsModel.TargetListItem.Package(repository: pair.package.repository,
423+
description: pair.package.summary,
424+
versions: versions,
425+
collections: Array(pair.collections))
426+
}
427+
428+
return PackageCollectionsModel.TargetListItem(target: target, packages: targetsPackages)
429+
})
430+
431+
callback(.success(result))
432+
}
433+
}
434+
}
435+
436+
// for testing
437+
internal func resetCache() {
438+
self.cacheLock.withLock {
439+
self.cache = [:]
440+
}
278441
}
279442

443+
// MARK: - Private
444+
280445
private func createSchemaIfNecessary(db: SQLite) throws {
281446
let table = """
282447
CREATE TABLE IF NOT EXISTS PACKAGES_COLLECTIONS (

Tests/PackageCollectionsTests/PackageCollectionsStorageTests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ class PackageCollectionsStorageTests: XCTestCase {
8989
XCTAssertTrue(storage.fileSystem.exists(storagePath), "expected file to exist at \(storagePath)")
9090

9191
try storage.fileSystem.removeFileTree(storagePath)
92+
storage.resetCache()
9293

9394
XCTAssertThrowsError(try tsc_await { callback in storage.get(identifier: mockCollections.first!.identifier, callback: callback) }, "expected error", { error in
9495
XCTAssert(error is NotFoundError, "Expected NotFoundError")
@@ -123,6 +124,7 @@ class PackageCollectionsStorageTests: XCTestCase {
123124
}
124125

125126
try storage.close()
127+
storage.resetCache()
126128

127129
XCTAssertTrue(storage.fileSystem.exists(storagePath), "expected file to exist at \(path)")
128130
try storage.fileSystem.writeFileContents(storagePath, bytes: ByteString("blah".utf8))

0 commit comments

Comments
 (0)