Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #2400 Shared cache for built dependencies #2716

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion Source/CarthageKit/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public struct Constants {
}()

/// ~/Library/Caches/org.carthage.CarthageKit/
private static let userCachesURL: URL = {
internal static let userCachesURL: URL = {
let fileManager = FileManager.default

let urlResult: Result<URL, NSError> = Result(catching: {
Expand Down
153 changes: 151 additions & 2 deletions Source/CarthageKit/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public enum ProjectEvent {
/// Building the project is being skipped because it is cached.
case skippedBuildingCached(Dependency)

/// Copying from shared cache
case skippedBuildingCopyFromSharedCache(Dependency)

/// Rebuilding a cached project because of a version file/framework mismatch.
case rebuildingCached(Dependency)

Expand Down Expand Up @@ -132,8 +135,7 @@ public final class Project { // swiftlint:disable:this type_body_length
private var cachedBinaryProjects: CachedBinaryProjects = [:]
private let cachedBinaryProjectsQueue = SerialProducerQueue(name: "org.carthage.Constants.Project.cachedBinaryProjectsQueue")

private lazy var xcodeVersionDirectory: String = XcodeVersion.make()
.map { "\($0.version)_\($0.buildVersion)" } ?? "Unknown"
private lazy var xcodeVersionDirectory: String = XcodeVersion.makeString()

/// Attempts to load Cartfile or Cartfile.private from the given directory,
/// merging their dependencies.
Expand Down Expand Up @@ -1160,6 +1162,25 @@ public final class Project { // swiftlint:disable:this type_body_length
return dependenciesIncludingNext
}
}
.flatMap(.concat) { (dependencies: [(Dependency, PinnedVersion)]) -> SignalProducer<[(Dependency, PinnedVersion)], CarthageError> in
if options.cacheBuilds {
return self.copyBuiltFrameworksFromSharedCacheIfVersionMatches(for: dependencies, options: options)
.flatMap(.concat) { dependency, version -> SignalProducer<(Dependency, PinnedVersion), CarthageError> in
self._projectEventsObserver.send(value: .skippedBuildingCopyFromSharedCache(dependency))
return SignalProducer(value: (dependency, version))
}
.collect()
.map { copiedFromSharedCacheDependencies -> [(Dependency, PinnedVersion)] in
// Filters out dependencies that we've copied from shared cache
// but preserves the build order
return dependencies.filter { dependency -> Bool in
!copiedFromSharedCacheDependencies.contains { $0 == dependency }
}
}
} else {
return SignalProducer(dependencies).collect()
}
}
.flatMap(.concat) { (dependencies: [(Dependency, PinnedVersion)]) -> SignalProducer<(Dependency, PinnedVersion), CarthageError> in
return SignalProducer(dependencies)
.flatMap(.concurrent(limit: 4)) { dependency, version -> SignalProducer<(Dependency, PinnedVersion), CarthageError> in
Expand Down Expand Up @@ -1235,6 +1256,37 @@ public final class Project { // swiftlint:disable:this type_body_length
}
}

private func copyBuiltFrameworksFromSharedCacheIfVersionMatches(
for dependencies: [(Dependency, PinnedVersion)],
options: BuildOptions
) -> SignalProducer<(Dependency, PinnedVersion), CarthageError> {
return SignalProducer(dependencies)
.flatMap(.concat) { dependency, version -> SignalProducer<(Dependency, PinnedVersion, URL, Bool?), CarthageError> in
let sharedCacheURLForDependency = sharedCacheURLFor(toolchain: options.toolchain, dependency: dependency, pinnedVersion: version)

return SignalProducer.combineLatest(
SignalProducer(value: dependency),
SignalProducer(value: version),
SignalProducer(value: sharedCacheURLForDependency),
versionFileMatches(dependency, version: version, platforms: options.platforms,
rootDirectoryURL: sharedCacheURLForDependency, toolchain: options.toolchain))
}
.flatMap(.concat) { dependency, version, sharedCacheVersioned, sharedCacheMatches -> SignalProducer<(Dependency, PinnedVersion), CarthageError> in
if let sharedCacheVersionFileMatches = sharedCacheMatches, sharedCacheVersionFileMatches {
return copyFrameworks(in: sharedCacheVersioned, to: self.directoryURL, projectName: dependency.name, pinnedVersion: version, toolchain: options.toolchain)
.collect()
.flatMap(.merge) { urls -> SignalProducer<(Dependency, PinnedVersion), CarthageError> in
if urls.isEmpty {
return .empty
} else {
return SignalProducer(value: (dependency, version))
}
}
}
return .empty
}
}

private func symlinkBuildPathIfNeeded(for dependency: Dependency, version: PinnedVersion) -> SignalProducer<(), CarthageError> {
return dependencySet(for: dependency, version: version)
.flatMap(.merge) { dependencies -> SignalProducer<(), CarthageError> in
Expand Down Expand Up @@ -1474,6 +1526,103 @@ internal func frameworksInDirectory(_ directoryURL: URL) -> SignalProducer<URL,
}
}

internal func copyFrameworksAndCreateVersionFile(
_ frameworkURLs: [URL],
from directoryURL: URL,
to destinationURL: URL,
projectName: String,
pinnedVersion: PinnedVersion,
toolchain: String?
) -> SignalProducer<URL, CarthageError> {
return SignalProducer(frameworkURLs)
.flatMap(.merge) { frameworkURL -> SignalProducer<URL, CarthageError> in
copyFramework(frameworkURL, toFolder: destinationURL)
}
.flatMap(.merge) { frameworkURL -> SignalProducer<URL, CarthageError> in
return copyDSYMToBuildFolderForFramework(frameworkURL, fromDirectoryURL: directoryURL)
.then(copyBCSymbolMapsToBuildFolderForFramework(frameworkURL, fromDirectoryURL: directoryURL))
.then(SignalProducer(value: frameworkURL))
}
.collect()
.flatMap(.concat) { frameworkURLs -> SignalProducer<(), CarthageError> in
return createVersionFilesForFrameworks(
frameworkURLs,
fromDirectoryURL: destinationURL,
projectName: projectName,
commitish: pinnedVersion.commitish
)
}
.then(SignalProducer<URL, CarthageError>(value: directoryURL))
}

internal func copyFrameworks(
in directoryURL: URL,
to destinationURL: URL,
projectName: String,
pinnedVersion: PinnedVersion,
toolchain: String?
) -> SignalProducer<URL, CarthageError> {
return frameworksInDirectory(directoryURL)
.flatMap(.merge) { url -> SignalProducer<URL, CarthageError> in
return checkFrameworkCompatibility(url, usingToolchain: toolchain)
.mapError { error in CarthageError.internalError(description: error.description) }
}
.collect()
.flatMap(.merge, { frameworkURLs -> SignalProducer<URL, CarthageError> in
copyFrameworksAndCreateVersionFile(frameworkURLs, from: directoryURL, to: destinationURL,
projectName: projectName, pinnedVersion: pinnedVersion, toolchain: toolchain)
})
}

/// Copies any *.bcsymbolmap files matching the given framework and contained
/// within the given directory URL to the directory that the framework
/// resides within.
///
/// If no bcsymbolmap files are found for the given framework, completes with
/// no values.
///
/// Sends the URLs of the bcsymbolmap files after copying.
internal func copyBCSymbolMapsToBuildFolderForFramework(_ frameworkURL: URL, fromDirectoryURL directoryURL: URL) -> SignalProducer<URL, CarthageError> {
let destinationDirectoryURL = frameworkURL.deletingLastPathComponent()
return BCSymbolMapsForFramework(frameworkURL, inDirectoryURL: directoryURL)
.copyFileURLsIntoDirectory(destinationDirectoryURL)
}

/// Creates a .version file for all of the provided frameworks.
internal func createVersionFilesForFrameworks(
_ frameworkURLs: [URL],
fromDirectoryURL directoryURL: URL,
projectName: String,
commitish: String
) -> SignalProducer<(), CarthageError> {
return createVersionFileForCommitish(commitish, dependencyName: projectName, buildProducts: frameworkURLs,
rootDirectoryURL: directoryURL)
}

/// Copies the framework at the given URL into the specified folder.
///
/// Sends the URL to the framework after copying.
internal func copyFramework(_ frameworkURL: URL, toFolder folderURL: URL) -> SignalProducer<URL, CarthageError> {
return platformForFramework(frameworkURL)
.flatMap(.merge) { platform -> SignalProducer<URL, CarthageError> in
let platformFolderURL = folderURL.appendingPathComponent(platform.relativePath, isDirectory: true)
return SignalProducer(value: frameworkURL)
.copyFileURLsIntoDirectory(platformFolderURL)
}
}

/// Copies the DSYM matching the given framework and contained within the
/// given directory URL to the directory that the framework resides within.
///
/// If no dSYM is found for the given framework, completes with no values.
///
/// Sends the URL of the dSYM after copying.
internal func copyDSYMToBuildFolderForFramework(_ frameworkURL: URL, fromDirectoryURL directoryURL: URL) -> SignalProducer<URL, CarthageError> {
let destinationDirectoryURL = frameworkURL.deletingLastPathComponent()
return dSYMForFramework(frameworkURL, inDirectoryURL: directoryURL)
.copyFileURLsIntoDirectory(destinationDirectoryURL)
}

/// Sends the URL to each dSYM found in the given directory
internal func dSYMsInDirectory(_ directoryURL: URL) -> SignalProducer<URL, CarthageError> {
return filesInDirectory(directoryURL, "com.apple.xcode.dsym")
Expand Down
38 changes: 29 additions & 9 deletions Source/CarthageKit/Xcode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import ReactiveSwift
import ReactiveTask
import XCDBLD

/// Gets the shared cache file path URL for dependency at pinned version
/// ~/Library/Caches/org.carthage.CarthageKit/SharedCache/XcodeVersion/DependencyName/Version, exemple :
/// ~/Library/Caches/org.carthage.CarthageKit/SharedCache/9.4.1_9F2000/SwiftyUserDefaults/3.0.1
internal func sharedCacheURLFor(toolchain: String?, dependency: Dependency, pinnedVersion: PinnedVersion) -> URL {
let sharedCacheURL = Constants.userCachesURL.appendingPathComponent("SharedCache")
let noSpacesToolchain = (toolchain ?? XcodeVersion.makeString()).replacingOccurrences(of: " ", with: "_")
let sharedCachePerToolchain = sharedCacheURL.appendingPathComponent(noSpacesToolchain, isDirectory: true)
let sharedCachePerDependency = sharedCachePerToolchain.appendingPathComponent(dependency.name, isDirectory: true)
let sharedCacheVersioned = sharedCachePerDependency.appendingPathComponent(pinnedVersion.commitish, isDirectory: true)
return sharedCacheVersioned
}

/// Emits the currect Swift version
internal func swiftVersion(usingToolchain toolchain: String? = nil) -> SignalProducer<String, SwiftVersionError> {
return determineSwiftVersion(usingToolchain: toolchain).replayLazily(upTo: 1)
Expand Down Expand Up @@ -955,15 +967,23 @@ public func buildInDirectory( // swiftlint:disable:this function_body_length
)
.flatMapError { _ in .empty }
}

return createVersionFile(
for: dependency.dependency,
version: dependency.version,
platforms: options.platforms,
buildProducts: urls,
rootDirectoryURL: rootDirectoryURL
)
.flatMapError { _ in .empty }
var copyToSharedCache = SignalProducer<(URL), CarthageError>.empty
if options.cacheBuilds {
let sharedCacheURLForDependency = sharedCacheURLFor(toolchain: options.toolchain, dependency: dependency.dependency, pinnedVersion: dependency.version)
copyToSharedCache = copyFrameworksAndCreateVersionFile(urls, from: rootDirectoryURL, to: sharedCacheURLForDependency,
projectName: dependency.dependency.name, pinnedVersion: dependency.version, toolchain: options.toolchain)
kenji21 marked this conversation as resolved.
Show resolved Hide resolved
.flatMapError { _ in .empty }
}
return copyToSharedCache.then(
createVersionFile(
for: dependency.dependency,
version: dependency.version,
platforms: options.platforms,
buildProducts: urls,
rootDirectoryURL: rootDirectoryURL
)
.flatMapError { _ in SignalProducer<(), CarthageError>.empty }
)
}
// Discard any Success values, since we want to
// use our initial value instead of waiting for
Expand Down
4 changes: 4 additions & 0 deletions Source/XCDBLD/XcodeVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,8 @@ public struct XcodeVersion {
}
.single()?.value
}

public static func makeString() -> String {
return make().map { "\($0.version)_\($0.buildVersion)" } ?? "Unknown"
}
}
3 changes: 3 additions & 0 deletions Source/carthage/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ internal struct ProjectEventSink {
case let .skippedBuildingCached(dependency):
carthage.println(formatting.bullets + "Valid cache found for " + formatting.projectName(dependency.name) + ", skipping build")

case let .skippedBuildingCopyFromSharedCache(dependency):
carthage.println(formatting.bullets + "Copying from shared cache for " + formatting.projectName(dependency.name) + ", skipping build")

case let .rebuildingCached(dependency):
carthage.println(formatting.bullets + "Invalid cache found for " + formatting.projectName(dependency.name)
+ ", rebuilding with all downstream dependencies")
Expand Down
26 changes: 26 additions & 0 deletions Tests/CarthageKitTests/ProjectSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ class ProjectSpec: QuickSpec {
func buildNoSharedSchemesTest(platforms: Set<SDK>? = nil, cacheBuilds: Bool = true, dependenciesToBuild: [String]? = nil) -> [String] {
return build(directoryURL: noSharedSchemesDirectoryURL, platforms: platforms, cacheBuilds: cacheBuilds, dependenciesToBuild: dependenciesToBuild)
}

func cleanSharedCache() {
_ = try? FileManager.default.removeItem(at: Constants.userCachesURL.appendingPathComponent("SharedCache"))
}

beforeEach {
cleanSharedCache()
_ = try? FileManager.default.removeItem(at: buildDirectoryURL)
// Pre-fetch the repos so we have a cache for the given tags
let sourceRepoUrl = directoryURL.appendingPathComponent("SourceRepos")
Expand Down Expand Up @@ -168,6 +173,7 @@ class ProjectSpec: QuickSpec {
let result2 = buildDependencyTest(platforms: [.macOS])
expect(result2) == []

cleanSharedCache()
let result3 = buildDependencyTest(platforms: [.macOS], cacheBuilds: false)
expect(result3) == expected
}
Expand All @@ -180,6 +186,7 @@ class ProjectSpec: QuickSpec {

overwriteFramework("TestFramework3", type: .dynamic, forPlatformName: "Mac", inDirectory: buildDirectoryURL)

cleanSharedCache()
let result2 = buildDependencyTest(platforms: [.macOS])
expect(result2) == expected
}
Expand All @@ -197,6 +204,7 @@ class ProjectSpec: QuickSpec {
let modifiedJson = json.replacingOccurrences(of: "\"commitish\" : \"v1.0\"", with: "\"commitish\" : \"v1.1\"")
_ = try! modifiedJson.write(toFile: preludeVersionFilePath, atomically: true, encoding: .utf8)

cleanSharedCache()
let result2 = buildDependencyTest(platforms: [.macOS])
expect(result2) == expected
}
Expand All @@ -217,6 +225,7 @@ class ProjectSpec: QuickSpec {
.reduce(true) { acc, next in return acc && next }
expect(allDSymsRemoved) == true

cleanSharedCache()
let result2 = buildDependencyTest(platforms: [.macOS])
expect(result2) == expected
}
Expand All @@ -229,9 +238,24 @@ class ProjectSpec: QuickSpec {

overwriteFramework("TestFramework2", type: .dynamic, forPlatformName: "Mac", inDirectory: buildDirectoryURL)

cleanSharedCache()
let result2 = buildDependencyTest(platforms: [.macOS])
expect(result2) == ["TestFramework2_Mac", "TestFramework1_Mac"]
}

it("should not rebuild shared cached frameworks unnecessarily") {
let expected = ["TestFramework3_Mac", "TestFramework2_Mac", "TestFramework1_Mac"]

let result1 = buildDependencyTest(platforms: [.macOS])
expect(result1) == expected

overwriteFramework("TestFramework1", type: .static, forPlatformName: "Mac", inDirectory: buildDirectoryURL)
overwriteFramework("TestFramework2", type: .dynamic, forPlatformName: "Mac", inDirectory: buildDirectoryURL)
overwriteFramework("TestFramework3", type: .dynamic, forPlatformName: "Mac", inDirectory: buildDirectoryURL)

let result2 = buildDependencyTest(platforms: [.macOS])
expect(result2) == []
}

it("should not rebuild cached frameworks (and dependencies) unnecessarily based on dSYMs") {
let expected = ["TestFramework3_Mac", "TestFramework2_Mac", "TestFramework1_Mac"]
Expand Down Expand Up @@ -259,6 +283,7 @@ class ProjectSpec: QuickSpec {

overwriteFramework("TestFramework1", type: .static, forPlatformName: "Mac", inDirectory: buildDirectoryURL)

cleanSharedCache()
let result2 = buildDependencyTest()
expect(result2.filter { $0.contains("Mac") }) == ["TestFramework1_Mac"]
expect(result2.filter { $0.contains("iOS") }) == ["TestFramework1_iOS"]
Expand All @@ -279,6 +304,7 @@ class ProjectSpec: QuickSpec {
let modifiedJson = json.replacingOccurrences(of: "\"commitish\" : \"v1.0\"", with: "\"commitish\" : \"v1.1\"")
_ = try! modifiedJson.write(toFile: framework2VersionFilePath, atomically: true, encoding: .utf8)

cleanSharedCache()
let result3 = buildNoSharedSchemesTest(platforms: [.iOS])
expect(result3) == ["TestFramework1_iOS"]
}
Expand Down