Skip to content

Commit 147a3f2

Browse files
authored
improve registry identity lookup cache (#6112)
motivation: avoid unecessary server lookups changes: * treat 404 as valid response code * cache both positive and negative server results to avoid redundant server calls rdar://104954616
1 parent 7b74d28 commit 147a3f2

File tree

3 files changed

+242
-91
lines changed

3 files changed

+242
-91
lines changed

Sources/PackageRegistry/RegistryClient.swift

Lines changed: 100 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public final class RegistryClient: Cancellable {
7373
}
7474

7575
public var configured: Bool {
76-
return !self.configuration.isEmpty
76+
!self.configuration.isEmpty
7777
}
7878

7979
/// Cancel any outstanding requests
@@ -118,7 +118,11 @@ public final class RegistryClient: Cancellable {
118118
self.httpClient.execute(request, observabilityScope: observabilityScope, progress: nil) { result in
119119
completion(
120120
result.tryMap { response in
121-
try self.checkResponseStatusAndHeaders(response, expectedStatusCode: 200, expectedContentType: .json)
121+
try self.checkResponseStatusAndHeaders(
122+
response,
123+
expectedStatusCode: 200,
124+
expectedContentType: .json
125+
)
122126

123127
guard let data = response.body else {
124128
throw RegistryError.invalidResponse
@@ -134,7 +138,7 @@ public final class RegistryClient: Cancellable {
134138
return PackageMetadata(
135139
registry: registry,
136140
versions: versions,
137-
alternateLocations: alternateLocations?.map { $0.url }
141+
alternateLocations: alternateLocations?.map(\.url)
138142
)
139143
}.mapError {
140144
RegistryError.failedRetrievingReleases($0)
@@ -182,7 +186,11 @@ public final class RegistryClient: Cancellable {
182186
self.httpClient.execute(request, observabilityScope: observabilityScope, progress: nil) { result in
183187
completion(
184188
result.tryMap { response in
185-
try self.checkResponseStatusAndHeaders(response, expectedStatusCode: 200, expectedContentType: .swift)
189+
try self.checkResponseStatusAndHeaders(
190+
response,
191+
expectedStatusCode: 200,
192+
expectedContentType: .swift
193+
)
186194

187195
guard let data = response.body else {
188196
throw RegistryError.invalidResponse
@@ -197,7 +205,8 @@ public final class RegistryClient: Cancellable {
197205

198206
let alternativeManifests = try response.headers.parseManifestLinks()
199207
for alternativeManifest in alternativeManifests {
200-
result[alternativeManifest.filename] = (toolsVersion: alternativeManifest.toolsVersion, content: .none)
208+
result[alternativeManifest.filename] = (toolsVersion: alternativeManifest.toolsVersion,
209+
content: .none)
201210
}
202211
return result
203212
}.mapError {
@@ -253,7 +262,11 @@ public final class RegistryClient: Cancellable {
253262
self.httpClient.execute(request, observabilityScope: observabilityScope, progress: nil) { result in
254263
completion(
255264
result.tryMap { response -> String in
256-
try self.checkResponseStatusAndHeaders(response, expectedStatusCode: 200, expectedContentType: .swift)
265+
try self.checkResponseStatusAndHeaders(
266+
response,
267+
expectedStatusCode: 200,
268+
expectedContentType: .swift
269+
)
257270

258271
guard let data = response.body else {
259272
throw RegistryError.invalidResponse
@@ -310,14 +323,19 @@ public final class RegistryClient: Cancellable {
310323
switch result {
311324
case .success(let response):
312325
do {
313-
try self.checkResponseStatusAndHeaders(response, expectedStatusCode: 200, expectedContentType: .json)
326+
try self.checkResponseStatusAndHeaders(
327+
response,
328+
expectedStatusCode: 200,
329+
expectedContentType: .json
330+
)
314331

315332
guard let data = response.body else {
316333
throw RegistryError.invalidResponse
317334
}
318335

319336
let versionMetadata = try self.jsonDecoder.decode(Serialization.VersionMetadata.self, from: data)
320-
guard let sourceArchive = versionMetadata.resources.first(where: { $0.name == "source-archive" }) else {
337+
guard let sourceArchive = versionMetadata.resources.first(where: { $0.name == "source-archive" })
338+
else {
321339
throw RegistryError.missingSourceArchive
322340
}
323341

@@ -330,16 +348,21 @@ public final class RegistryClient: Cancellable {
330348
version: version,
331349
fingerprint: .init(origin: .registry(registry.url), value: checksum),
332350
observabilityScope: observabilityScope,
333-
callbackQueue: callbackQueue) { storageResult in
351+
callbackQueue: callbackQueue)
352+
{ storageResult in
334353
switch storageResult {
335354
case .success:
336355
completion(.success(checksum))
337356
case .failure(PackageFingerprintStorageError.conflict(_, let existing)):
338357
switch self.fingerprintCheckingMode {
339358
case .strict:
340-
completion(.failure(RegistryError.checksumChanged(latest: checksum, previous: existing.value)))
359+
completion(.failure(RegistryError
360+
.checksumChanged(latest: checksum, previous: existing.value)))
341361
case .warn:
342-
observabilityScope.emit(warning: "The checksum \(checksum) from \(registry.url.absoluteString) does not match previously recorded value \(existing.value) from \(String(describing: existing.origin.url?.absoluteString))")
362+
observabilityScope
363+
.emit(
364+
warning: "The checksum \(checksum) from \(registry.url.absoluteString) does not match previously recorded value \(existing.value) from \(String(describing: existing.origin.url?.absoluteString))"
365+
)
343366
completion(.success(checksum))
344367
}
345368
case .failure(let error):
@@ -435,9 +458,13 @@ public final class RegistryClient: Cancellable {
435458
if expectedChecksum != actualChecksum {
436459
switch self.fingerprintCheckingMode {
437460
case .strict:
438-
return completion(.failure(RegistryError.invalidChecksum(expected: expectedChecksum, actual: actualChecksum)))
461+
return completion(.failure(RegistryError
462+
.invalidChecksum(expected: expectedChecksum, actual: actualChecksum)))
439463
case .warn:
440-
observabilityScope.emit(warning: "The checksum \(actualChecksum) does not match previously recorded value \(expectedChecksum)")
464+
observabilityScope
465+
.emit(
466+
warning: "The checksum \(actualChecksum) does not match previously recorded value \(expectedChecksum)"
467+
)
441468
}
442469
}
443470
// validate that the destination does not already exist (again, as this is async)
@@ -478,13 +505,17 @@ public final class RegistryClient: Cancellable {
478505
version: version,
479506
kind: .registry,
480507
observabilityScope: observabilityScope,
481-
callbackQueue: callbackQueue) { result in
508+
callbackQueue: callbackQueue)
509+
{ result in
482510
switch result {
483511
case .success(let fingerprint):
484512
body(.success(fingerprint.value))
485513
case .failure(let error):
486514
if error as? PackageFingerprintStorageError != .notFound {
487-
observabilityScope.emit(error: "Failed to get registry fingerprint for \(package) \(version) from storage: \(error)")
515+
observabilityScope
516+
.emit(
517+
error: "Failed to get registry fingerprint for \(package) \(version) from storage: \(error)"
518+
)
488519
}
489520
// Try fetching checksum from registry again no matter which kind of error it is
490521
self.fetchSourceArchiveChecksum(package: package,
@@ -541,6 +572,11 @@ public final class RegistryClient: Cancellable {
541572

542573
self.httpClient.execute(request, observabilityScope: observabilityScope, progress: nil) { result in
543574
completion(result.tryMap { response in
575+
// 404 is valid, no identities mapped
576+
if response.statusCode == 404 {
577+
return []
578+
}
579+
544580
try self.checkResponseStatusAndHeaders(response, expectedStatusCode: 200, expectedContentType: .json)
545581

546582
guard let data = response.body else {
@@ -588,7 +624,9 @@ public final class RegistryClient: Cancellable {
588624
}
589625
}
590626

591-
private func makeAsync<T>(_ closure: @escaping (Result<T, Error>) -> Void, on queue: DispatchQueue) -> (Result<T, Error>) -> Void {
627+
private func makeAsync<T>(_ closure: @escaping (Result<T, Error>) -> Void,
628+
on queue: DispatchQueue) -> (Result<T, Error>) -> Void
629+
{
592630
{ result in queue.async { closure(result) } }
593631
}
594632

@@ -679,30 +717,34 @@ public enum RegistryError: Error, CustomStringConvertible {
679717
}
680718
}
681719

682-
private extension RegistryClient {
683-
enum APIVersion: String {
720+
extension RegistryClient {
721+
fileprivate enum APIVersion: String {
684722
case v1 = "1"
685723
}
686724
}
687725

688-
private extension RegistryClient {
689-
enum MediaType: String {
726+
extension RegistryClient {
727+
fileprivate enum MediaType: String {
690728
case json
691729
case swift
692730
case zip
693731
}
694732

695-
enum ContentType: String {
733+
fileprivate enum ContentType: String {
696734
case json = "application/json"
697735
case swift = "text/x-swift"
698736
case zip = "application/zip"
699737
}
700738

701-
func acceptHeader(mediaType: MediaType) -> String {
739+
private func acceptHeader(mediaType: MediaType) -> String {
702740
"application/vnd.swift.registry.v\(self.apiVersion.rawValue)+\(mediaType)"
703741
}
704742

705-
func checkResponseStatusAndHeaders(_ response: HTTPClient.Response, expectedStatusCode: Int, expectedContentType: ContentType) throws {
743+
private func checkResponseStatusAndHeaders(
744+
_ response: HTTPClient.Response,
745+
expectedStatusCode: Int,
746+
expectedContentType: ContentType
747+
) throws {
706748
guard response.statusCode == expectedStatusCode else {
707749
throw RegistryError.invalidResponseStatus(expected: expectedStatusCode, actual: response.statusCode)
708750
}
@@ -727,8 +769,8 @@ extension RegistryClient {
727769
}
728770
}
729771

730-
private extension RegistryClient {
731-
struct AlternativeLocationLink {
772+
extension RegistryClient {
773+
fileprivate struct AlternativeLocationLink {
732774
let url: URL
733775
let kind: Kind
734776

@@ -739,21 +781,21 @@ private extension RegistryClient {
739781
}
740782
}
741783

742-
private extension RegistryClient {
743-
struct ManifestLink {
784+
extension RegistryClient {
785+
fileprivate struct ManifestLink {
744786
let url: URL
745787
let filename: String
746788
let toolsVersion: ToolsVersion
747789
}
748790
}
749791

750-
private extension HTTPClientHeaders {
792+
extension HTTPClientHeaders {
751793
/*
752794
<https://github.com/mona/LinkedList>; rel="canonical",
753795
<ssh://[email protected]:mona/LinkedList.git>; rel="alternate",
754796
*/
755-
func parseAlternativeLocationLinks() throws -> [RegistryClient.AlternativeLocationLink]? {
756-
return try self.get("Link").map { header -> [RegistryClient.AlternativeLocationLink] in
797+
fileprivate func parseAlternativeLocationLinks() throws -> [RegistryClient.AlternativeLocationLink]? {
798+
try self.get("Link").map { header -> [RegistryClient.AlternativeLocationLink] in
757799
let linkLines = header.split(separator: ",").map(String.init).map { $0.spm_chuzzle() ?? $0 }
758800
return try linkLines.compactMap { linkLine in
759801
try parseAlternativeLocationLine(linkLine)
@@ -770,11 +812,15 @@ private extension HTTPClientHeaders {
770812
return nil
771813
}
772814

773-
guard let link = fields.first(where: { $0.hasPrefix("<") }).map({ String($0.dropFirst().dropLast()) }), let url = URL(string: link) else {
815+
guard let link = fields.first(where: { $0.hasPrefix("<") }).map({ String($0.dropFirst().dropLast()) }),
816+
let url = URL(string: link)
817+
else {
774818
return nil
775819
}
776820

777-
guard let rel = fields.first(where: { $0.hasPrefix("rel=") }).flatMap({ parseLinkFieldValue($0) }), let kind = RegistryClient.AlternativeLocationLink.Kind(rawValue: rel) else {
821+
guard let rel = fields.first(where: { $0.hasPrefix("rel=") }).flatMap({ parseLinkFieldValue($0) }),
822+
let kind = RegistryClient.AlternativeLocationLink.Kind(rawValue: rel)
823+
else {
778824
return nil
779825
}
780826

@@ -785,12 +831,12 @@ private extension HTTPClientHeaders {
785831
}
786832
}
787833

788-
private extension HTTPClientHeaders {
834+
extension HTTPClientHeaders {
789835
/*
790836
<http://packages.example.com/mona/LinkedList/1.1.1/Package.swift?swift-version=4>; rel="alternate"; filename="[email protected]"; swift-tools-version="4.0"
791837
*/
792-
func parseManifestLinks() throws -> [RegistryClient.ManifestLink] {
793-
return try self.get("Link").map { header -> [RegistryClient.ManifestLink] in
838+
fileprivate func parseManifestLinks() throws -> [RegistryClient.ManifestLink] {
839+
try self.get("Link").map { header -> [RegistryClient.ManifestLink] in
794840
let linkLines = header.split(separator: ",").map(String.init).map { $0.spm_chuzzle() ?? $0 }
795841
return try linkLines.compactMap { linkLine in
796842
try parseManifestLinkLine(linkLine)
@@ -807,19 +853,26 @@ private extension HTTPClientHeaders {
807853
return nil
808854
}
809855

810-
guard let link = fields.first(where: { $0.hasPrefix("<") }).map({ String($0.dropFirst().dropLast()) }), let url = URL(string: link) else {
856+
guard let link = fields.first(where: { $0.hasPrefix("<") }).map({ String($0.dropFirst().dropLast()) }),
857+
let url = URL(string: link)
858+
else {
811859
return nil
812860
}
813861

814-
guard let rel = fields.first(where: { $0.hasPrefix("rel=") }).flatMap({ parseLinkFieldValue($0) }), rel == "alternate" else {
862+
guard let rel = fields.first(where: { $0.hasPrefix("rel=") }).flatMap({ parseLinkFieldValue($0) }),
863+
rel == "alternate"
864+
else {
815865
return nil
816866
}
817867

818-
guard let filename = fields.first(where: { $0.hasPrefix("filename=") }).flatMap({ parseLinkFieldValue($0) }) else {
868+
guard let filename = fields.first(where: { $0.hasPrefix("filename=") }).flatMap({ parseLinkFieldValue($0) })
869+
else {
819870
return nil
820871
}
821872

822-
guard let toolsVersion = fields.first(where: { $0.hasPrefix("swift-tools-version=") }).flatMap({ parseLinkFieldValue($0) }) else {
873+
guard let toolsVersion = fields.first(where: { $0.hasPrefix("swift-tools-version=") })
874+
.flatMap({ parseLinkFieldValue($0) })
875+
else {
823876
return nil
824877
}
825878

@@ -835,8 +888,8 @@ private extension HTTPClientHeaders {
835888
}
836889
}
837890

838-
private extension HTTPClientHeaders {
839-
func parseLinkFieldValue(_ field: String) -> String? {
891+
extension HTTPClientHeaders {
892+
private func parseLinkFieldValue(_ field: String) -> String? {
840893
let parts = field.split(separator: "=")
841894
.map(String.init)
842895
.map { $0.spm_chuzzle() ?? $0 }
@@ -852,8 +905,8 @@ private extension HTTPClientHeaders {
852905
// MARK: - Serialization
853906

854907
// marked public for cross module visibility
855-
public extension RegistryClient {
856-
enum Serialization {
908+
extension RegistryClient {
909+
public enum Serialization {
857910
public struct PackageMetadata: Codable {
858911
public let releases: [String: Release]
859912

@@ -937,16 +990,16 @@ public extension RegistryClient {
937990

938991
// MARK: - Utilities
939992

940-
private extension AbsolutePath {
941-
func withExtension(_ extension: String) -> AbsolutePath {
993+
extension AbsolutePath {
994+
fileprivate func withExtension(_ extension: String) -> AbsolutePath {
942995
guard !self.isRoot else { return self }
943996
let `extension` = `extension`.spm_dropPrefix(".")
944997
return self.parentDirectory.appending(component: "\(basename).\(`extension`)")
945998
}
946999
}
9471000

948-
private extension URLComponents {
949-
mutating func appendPathComponents(_ components: String...) {
1001+
extension URLComponents {
1002+
fileprivate mutating func appendPathComponents(_ components: String...) {
9501003
path += (path.last == "/" ? "" : "/") + components.joined(separator: "/")
9511004
}
9521005
}

Sources/Workspace/Workspace.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3715,7 +3715,7 @@ extension Workspace {
37153715
private let transformationMode: TransformationMode
37163716

37173717
private let cacheTTL = DispatchTimeInterval.seconds(300) // 5m
3718-
private let identitiesCache = ThreadSafeKeyValueStore<URL, (identity: PackageIdentity, expirationTime: DispatchTime)>()
3718+
private let identityLookupCache = ThreadSafeKeyValueStore<URL, (result: Result<PackageIdentity?, Error>, expirationTime: DispatchTime)>()
37193719

37203720
init(underlying: ManifestLoaderProtocol, registryClient: RegistryClient, transformationMode: TransformationMode) {
37213721
self.underlying = underlying
@@ -3946,18 +3946,25 @@ extension Workspace {
39463946
callbackQueue: DispatchQueue,
39473947
completion: @escaping (Result<PackageIdentity?, Error>) -> Void
39483948
) {
3949-
if let cached = self.identitiesCache[url], cached.expirationTime > .now() {
3950-
return completion(.success(cached.identity))
3949+
if let cached = self.identityLookupCache[url], cached.expirationTime > .now() {
3950+
switch cached.result {
3951+
case .success(let identity):
3952+
return completion(.success(identity))
3953+
case .failure:
3954+
// server error, do not try again
3955+
return completion(.success(.none))
3956+
}
39513957
}
39523958

39533959
self.registryClient.lookupIdentities(url: url, observabilityScope: observabilityScope, callbackQueue: callbackQueue) { result in
39543960
switch result {
39553961
case .failure(let error):
3962+
self.identityLookupCache[url] = (result: .failure(error), expirationTime: .now() + self.cacheTTL)
39563963
completion(.failure(error))
39573964
case .success(let identities):
39583965
// FIXME: returns first result... need to consider how to address multiple ones
39593966
let identity = identities.first
3960-
self.identitiesCache[url] = identity.map { (identity: $0, expirationTime: .now() + self.cacheTTL) }
3967+
self.identityLookupCache[url] = (result: .success(identity), expirationTime: .now() + self.cacheTTL)
39613968
completion(.success(identity))
39623969
}
39633970
}

0 commit comments

Comments
 (0)