Skip to content

Commit 4e029ce

Browse files
authored
Parallelize retrieving resolved packages (#8220)
Parallelize retrieving resolved packages ### Motivation: This PR brings back the parallelization reverted [here](#8217). As raised in the revertal, checkouts and registry downloads mutate the `WorkspaceState` – and running these operations in parallel can lead to a crash since the `WorkspaceState` is not thread-safe. ### Modifications: Based on [this](#8217 (comment)) suggestion, I'm making `WorkspaceState` an `actor` to make access to it thread-safe. Additionally, I turned `ManagedDependencies` into a `struct` to ensure its thread-safety, too. There are two methods of this struct that mutate self – `add` and `remove`. I changed this to return a new copy of `ManagedDependencies` instead and add counterpart methods in `WorkspaceState` where the mutation is done in-place. Since `ManagedDependencies` is a `Collection`, making it into an `actor` because of two mutable methods didn't feel like the right fit. However, let me know what you think about it. I added comments to the key modifications to make it easier to find the more interesting modifications since there's a lot of noise since I had to add loads of `async` and `await`. You can find these comments below. ### Result: [Retrieving resolved packages](https://github.com/swiftlang/swift-package-manager/blob/0340bb12a56f9696b3966ad82c2aee1594135377/Sources/Workspace/Workspace%2BDependencies.swift#L428) in parallel should not lead to crashes. As suggested by @bkhouri [here](#8217 (comment)), I ran `WorkspaceTests` multiple times to see if I run into any thread-safety issues: ``` for ((i=1; i<=30; i++)); do swift test --filter WorkspaceTests.WorkspaceTests; done for ((i=1; i<=10; i++)); do swift test --filter WorkspaceTests; done ```
1 parent af93a3c commit 4e029ce

17 files changed

+389
-358
lines changed

Sources/Commands/PackageCommands/ResetCommands.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,15 @@ extension SwiftPackageCommand {
3838
}
3939
}
4040

41-
struct Reset: SwiftCommand {
41+
struct Reset: AsyncSwiftCommand {
4242
static let configuration = CommandConfiguration(
4343
abstract: "Reset the complete cache/build directory")
4444

4545
@OptionGroup(visibility: .hidden)
4646
var globalOptions: GlobalOptions
4747

48-
func run(_ swiftCommandState: SwiftCommandState) throws {
49-
try swiftCommandState.getActiveWorkspace().reset(observabilityScope: swiftCommandState.observabilityScope)
48+
func run(_ swiftCommandState: SwiftCommandState) async throws {
49+
try await swiftCommandState.getActiveWorkspace().reset(observabilityScope: swiftCommandState.observabilityScope)
5050
}
5151
}
5252
}

Sources/CoreCommands/BuildSystemSupport.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,15 @@ private struct NativeBuildSystemFactory: BuildSystemFactory {
3838
) async throws -> any BuildSystem {
3939
_ = try await swiftCommandState.getRootPackageInformation()
4040
let testEntryPointPath = productsBuildParameters?.testProductStyle.explicitlySpecifiedEntryPointPath
41+
let cacheBuildManifest = if cacheBuildManifest {
42+
try await self.swiftCommandState.canUseCachedBuildManifest()
43+
} else {
44+
false
45+
}
4146
return try BuildOperation(
4247
productsBuildParameters: try productsBuildParameters ?? self.swiftCommandState.productsBuildParameters,
4348
toolsBuildParameters: try toolsBuildParameters ?? self.swiftCommandState.toolsBuildParameters,
44-
cacheBuildManifest: cacheBuildManifest && self.swiftCommandState.canUseCachedBuildManifest(),
49+
cacheBuildManifest: cacheBuildManifest,
4550
packageGraphLoader: packageGraphLoader ?? {
4651
try await self.swiftCommandState.loadPackageGraph(
4752
explicitProduct: explicitProduct,

Sources/CoreCommands/SwiftCommandState.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,7 @@ public final class SwiftCommandState {
689689
try _manifestLoader.get()
690690
}
691691

692-
public func canUseCachedBuildManifest() throws -> Bool {
692+
public func canUseCachedBuildManifest() async throws -> Bool {
693693
if !self.options.caching.cacheBuildManifest {
694694
return false
695695
}
@@ -706,7 +706,7 @@ public final class SwiftCommandState {
706706
// Perform steps for build manifest caching if we can enabled it.
707707
//
708708
// FIXME: We don't add edited packages in the package structure command yet (SR-11254).
709-
let hasEditedPackages = try self.getActiveWorkspace().state.dependencies.contains(where: \.isEdited)
709+
let hasEditedPackages = try await self.getActiveWorkspace().state.dependencies.contains(where: \.isEdited)
710710
if hasEditedPackages {
711711
return false
712712
}

Sources/Workspace/ManagedDependency.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,18 @@ extension Workspace.ManagedDependency: CustomStringConvertible {
172172

173173
extension Workspace {
174174
/// A collection of managed dependencies.
175-
final public class ManagedDependencies {
175+
public struct ManagedDependencies {
176176
private var dependencies: [PackageIdentity: ManagedDependency]
177177

178178
init() {
179179
self.dependencies = [:]
180180
}
181+
182+
private init(
183+
_ dependencies: [PackageIdentity: ManagedDependency]
184+
) {
185+
self.dependencies = dependencies
186+
}
181187

182188
init(_ dependencies: [ManagedDependency]) throws {
183189
// rdar://86857825 do not use Dictionary(uniqueKeysWithValues:) as it can crash the process when input is incorrect such as in older versions of SwiftPM
@@ -204,12 +210,16 @@ extension Workspace {
204210
return .none
205211
}
206212

207-
public func add(_ dependency: ManagedDependency) {
208-
self.dependencies[dependency.packageRef.identity] = dependency
213+
public func add(_ dependency: ManagedDependency) -> Self {
214+
var dependencies = dependencies
215+
dependencies[dependency.packageRef.identity] = dependency
216+
return ManagedDependencies(dependencies)
209217
}
210218

211-
public func remove(_ identity: PackageIdentity) {
212-
self.dependencies[identity] = nil
219+
public func remove(_ identity: PackageIdentity) -> Self {
220+
var dependencies = dependencies
221+
dependencies[identity] = nil
222+
return ManagedDependencies(dependencies)
213223
}
214224
}
215225
}

Sources/Workspace/Workspace+BinaryArtifacts.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -811,7 +811,7 @@ extension Workspace {
811811
var artifactsToDownload: [BinaryArtifactsManager.RemoteArtifact] = []
812812
var artifactsToExtract: [ManagedArtifact] = []
813813

814-
for artifact in state.artifacts {
814+
for artifact in await state.artifacts {
815815
if !manifestArtifacts.local
816816
.contains(where: { $0.packageRef == artifact.packageRef && $0.targetName == artifact.targetName }) &&
817817
!manifestArtifacts.remote
@@ -822,7 +822,7 @@ extension Workspace {
822822
}
823823

824824
for artifact in manifestArtifacts.local {
825-
let existingArtifact = self.state.artifacts[
825+
let existingArtifact = await self.state.artifacts[
826826
packageIdentity: artifact.packageRef.identity,
827827
targetName: artifact.targetName
828828
]
@@ -859,7 +859,7 @@ extension Workspace {
859859
}
860860

861861
for artifact in manifestArtifacts.remote {
862-
let existingArtifact = self.state.artifacts[
862+
let existingArtifact = await self.state.artifacts[
863863
packageIdentity: artifact.packageRef.identity,
864864
targetName: artifact.targetName
865865
]
@@ -891,9 +891,9 @@ extension Workspace {
891891
}
892892

893893
// Remove the artifacts and directories which are not needed anymore.
894-
observabilityScope.trap {
894+
await observabilityScope.trap {
895895
for artifact in artifactsToRemove {
896-
state.artifacts.remove(packageIdentity: artifact.packageRef.identity, targetName: artifact.targetName)
896+
await state.artifacts.remove(packageIdentity: artifact.packageRef.identity, targetName: artifact.targetName)
897897

898898
if isAtArtifactsDirectory(artifact) {
899899
try fileSystem.removeFileTree(artifact.path)
@@ -930,15 +930,15 @@ extension Workspace {
930930

931931
// Add the new artifacts
932932
for artifact in artifactsToAdd {
933-
self.state.artifacts.add(artifact)
933+
await self.state.artifacts.add(artifact)
934934
}
935935

936936
guard !observabilityScope.errorsReported else {
937937
throw Diagnostics.fatalError
938938
}
939939

940-
observabilityScope.trap {
941-
try self.state.save()
940+
await observabilityScope.trap {
941+
try await self.state.save()
942942
}
943943

944944
func isAtArtifactsDirectory(_ artifact: ManagedArtifact) -> Bool {

Sources/Workspace/Workspace+Dependencies.swift

Lines changed: 43 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ extension Workspace {
169169
}
170170

171171
// Update the resolved file.
172-
try self.saveResolvedFile(
172+
try await self.saveResolvedFile(
173173
resolvedPackagesStore: resolvedPackagesStore,
174174
dependencyManifests: updatedDependencyManifests,
175175
originHash: resolvedFileOriginHash,
@@ -219,7 +219,7 @@ extension Workspace {
219219
case .update(let forceResolution):
220220
return try await resolveAndUpdateResolvedFile(forceResolution: forceResolution)
221221
case .bestEffort:
222-
guard !self.state.dependencies.hasEditedDependencies() else {
222+
guard await !self.state.dependencies.hasEditedDependencies() else {
223223
return try await resolveAndUpdateResolvedFile(forceResolution: false)
224224
}
225225
guard self.fileSystem.exists(self.location.resolvedVersionsFile) else {
@@ -410,9 +410,10 @@ extension Workspace {
410410
//
411411
// We require cloning if there is no checkout or if the checkout doesn't
412412
// match with the pin.
413+
let dependencies = await state.dependencies
413414
let requiredResolvedPackages = resolvedPackagesStore.resolvedPackages.values.filter { pin in
414415
// also compare the location in case it has changed
415-
guard let dependency = state.dependencies[comparingLocation: pin.packageRef] else {
416+
guard let dependency = dependencies[comparingLocation: pin.packageRef] else {
416417
return true
417418
}
418419
switch dependency.state {
@@ -426,26 +427,31 @@ extension Workspace {
426427
}
427428

428429
// Retrieve the required resolved packages.
429-
for resolvedPackage in requiredResolvedPackages {
430-
await observabilityScope.makeChildScope(
431-
description: "retrieving resolved package versions for dependencies",
432-
metadata: resolvedPackage.packageRef.diagnosticsMetadata
433-
).trap {
434-
switch resolvedPackage.packageRef.kind {
435-
case .localSourceControl, .remoteSourceControl:
436-
_ = try await self.checkoutRepository(
437-
package: resolvedPackage.packageRef,
438-
at: resolvedPackage.state,
439-
observabilityScope: observabilityScope
440-
)
441-
case .registry:
442-
_ = try await self.downloadRegistryArchive(
443-
package: resolvedPackage.packageRef,
444-
at: resolvedPackage.state,
445-
observabilityScope: observabilityScope
446-
)
447-
default:
448-
throw InternalError("invalid resolved package type \(resolvedPackage.packageRef.kind)")
430+
await withThrowingTaskGroup(of: Void.self) { taskGroup in
431+
for resolvedPackage in requiredResolvedPackages {
432+
let observabilityScope = observabilityScope.makeChildScope(
433+
description: "retrieving resolved package versions for dependencies",
434+
metadata: resolvedPackage.packageRef.diagnosticsMetadata
435+
)
436+
taskGroup.addTask {
437+
await observabilityScope.trap {
438+
switch resolvedPackage.packageRef.kind {
439+
case .localSourceControl, .remoteSourceControl:
440+
_ = try await self.checkoutRepository(
441+
package: resolvedPackage.packageRef,
442+
at: resolvedPackage.state,
443+
observabilityScope: observabilityScope
444+
)
445+
case .registry:
446+
_ = try await self.downloadRegistryArchive(
447+
package: resolvedPackage.packageRef,
448+
at: resolvedPackage.state,
449+
observabilityScope: observabilityScope
450+
)
451+
default:
452+
throw InternalError("invalid resolved package type \(resolvedPackage.packageRef.kind)")
453+
}
454+
}
449455
}
450456
}
451457
}
@@ -523,7 +529,7 @@ extension Workspace {
523529
}
524530

525531
// load and update the `Package.resolved` store with any changes from loading the top level dependencies
526-
guard let resolvedPackagesStore = self.loadAndUpdateResolvedPackagesStore(
532+
guard let resolvedPackagesStore = await self.loadAndUpdateResolvedPackagesStore(
527533
dependencyManifests: currentManifests,
528534
rootManifestsMinimumToolsVersion: rootManifestsMinimumToolsVersion,
529535
observabilityScope: observabilityScope
@@ -554,7 +560,7 @@ extension Workspace {
554560
case .notRequired:
555561
// since nothing changed we can exit early,
556562
// but need update resolved file and download an missing binary artifact
557-
try self.saveResolvedFile(
563+
try await self.saveResolvedFile(
558564
resolvedPackagesStore: resolvedPackagesStore,
559565
dependencyManifests: currentManifests,
560566
originHash: resolvedFileOriginHash,
@@ -625,7 +631,7 @@ extension Workspace {
625631
}
626632

627633
// Update the resolved file.
628-
try self.saveResolvedFile(
634+
try await self.saveResolvedFile(
629635
resolvedPackagesStore: resolvedPackagesStore,
630636
dependencyManifests: updatedDependencyManifests,
631637
originHash: resolvedFileOriginHash,
@@ -679,15 +685,15 @@ extension Workspace {
679685

680686
// First remove the checkouts that are no longer required.
681687
for (packageRef, state) in packageStateChanges {
682-
observabilityScope.makeChildScope(
688+
await observabilityScope.makeChildScope(
683689
description: "removing unneeded checkouts",
684690
metadata: packageRef.diagnosticsMetadata
685691
).trap {
686692
switch state {
687693
case .added, .updated, .unchanged:
688694
break
689695
case .removed:
690-
try self.remove(package: packageRef)
696+
try await self.remove(package: packageRef)
691697
}
692698
}
693699
}
@@ -772,8 +778,8 @@ extension Workspace {
772778
state: .custom(version: version, path: path),
773779
subpath: RelativePath(validating: "")
774780
)
775-
self.state.dependencies.add(dependency)
776-
try self.state.save()
781+
await self.state.add(dependency: dependency)
782+
try await self.state.save()
777783
return path
778784
} else {
779785
throw InternalError("invalid container for \(package.identity) of type \(package.kind)")
@@ -800,8 +806,8 @@ extension Workspace {
800806
throw InternalError("invalid package type: \(package.kind)")
801807
}
802808

803-
self.state.dependencies.add(dependency)
804-
try self.state.save()
809+
await self.state.add(dependency: dependency)
810+
try await self.state.save()
805811
return path
806812
}
807813
}
@@ -889,7 +895,7 @@ extension Workspace {
889895
dependencyManifests: DependencyManifests,
890896
rootManifestsMinimumToolsVersion: ToolsVersion,
891897
observabilityScope: ObservabilityScope
892-
) -> ResolvedPackagesStore? {
898+
) async -> ResolvedPackagesStore? {
893899
guard let resolvedPackagesStore = observabilityScope.trap({ try self.resolvedPackagesStore.load() }) else {
894900
return nil
895901
}
@@ -899,7 +905,7 @@ extension Workspace {
899905
else {
900906
return nil
901907
}
902-
for dependency in self.state.dependencies.filter(\.packageRef.kind.isResolvable) {
908+
for dependency in await self.state.dependencies.filter(\.packageRef.kind.isResolvable) {
903909
// a required dependency that is already loaded (managed) should be represented in the `Package.resolved` store.
904910
// also comparing location as it may have changed at this point
905911
if requiredDependencies.contains(where: { $0.equalsIncludingLocation(dependency.packageRef) }) {
@@ -1011,13 +1017,13 @@ extension Workspace {
10111017
// Get the existing managed dependency for this package ref, if any.
10121018

10131019
// first find by identity only since edit location may be different by design
1014-
var currentDependency = self.state.dependencies[binding.package.identity]
1020+
var currentDependency = await self.state.dependencies[binding.package.identity]
10151021
// Check if this is an edited dependency.
10161022
if case .edited(let basedOn, _) = currentDependency?.state, let originalReference = basedOn?.packageRef {
10171023
packageStateChanges[originalReference.identity] = (originalReference, .unchanged)
10181024
} else {
10191025
// if not edited, also compare by location since it may have changed
1020-
currentDependency = self.state.dependencies[comparingLocation: binding.package]
1026+
currentDependency = await self.state.dependencies[comparingLocation: binding.package]
10211027
}
10221028

10231029
switch binding.boundVersion {
@@ -1119,7 +1125,7 @@ extension Workspace {
11191125
}
11201126
}
11211127
// Set the state of any old package that might have been removed.
1122-
for packageRef in self.state.dependencies.lazy.map(\.packageRef)
1128+
for packageRef in await self.state.dependencies.lazy.map(\.packageRef)
11231129
where packageStateChanges[packageRef.identity] == nil
11241130
{
11251131
packageStateChanges[packageRef.identity] = (packageRef, .removed)

Sources/Workspace/Workspace+Editing.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ extension Workspace {
2828
observabilityScope: ObservabilityScope
2929
) async throws {
3030
// Look up the dependency and check if we can edit it.
31-
guard let dependency = self.state.dependencies[.plain(packageIdentity)] else {
31+
guard let dependency = await self.state.dependencies[.plain(packageIdentity)] else {
3232
observabilityScope.emit(.dependencyNotFound(packageName: packageIdentity))
3333
return
3434
}
@@ -157,10 +157,10 @@ extension Workspace {
157157
}
158158

159159
// Save the new state.
160-
try self.state.dependencies.add(
161-
dependency.edited(subpath: RelativePath(validating: packageIdentity), unmanagedPath: path)
160+
try await self.state.add(
161+
dependency: dependency.edited(subpath: RelativePath(validating: packageIdentity), unmanagedPath: path)
162162
)
163-
try self.state.save()
163+
try await self.state.save()
164164
}
165165

166166
/// Unedit a managed dependency. See public API unedit(packageName:forceRemove:).
@@ -222,8 +222,8 @@ extension Workspace {
222222
)
223223
} else {
224224
// The original dependency was removed, update the managed dependency state.
225-
self.state.dependencies.remove(dependency.packageRef.identity)
226-
try self.state.save()
225+
await self.state.remove(identity: dependency.packageRef.identity)
226+
try await self.state.save()
227227
}
228228

229229
// Resolve the dependencies if workspace root is provided. We do this to

0 commit comments

Comments
 (0)