Skip to content

Commit a0d30bc

Browse files
authored
Add initial support for Swift Concurrency (#117)
This change adds initial support for running certain publishing operations concurrently using Swift's new built-in concurrency system. This doesn't involve any breaking changes to the public API, apart from now requiring all Publish users to ensure that their HTML theme and multi-item mutation code can be executed in a concurrent manner. The following key changes were made as part of this: - Bump the project's Swift version to `5.5`, and update the CLI's project template accordingly. - Add CollectionConcurrencyKit as a new package dependency, which adds concurrent versions of the various sequence mapping functions that Publish makes heavy use of. - Make `PublishingStep` use an `async` closure type as its body, which in turn enables asynchronous calls to be made within any step, both built-in and custom ones. - Each publishing pipeline is still performed in sequence, just like before. So it's only the internal operations within each step that may now be parallelized. - The top-level `Website.publish` APIs are still completely synchronous, and uses an internal `DispatchSemaphore` to await the completion of the website's publishing pipeline before returning. - A new built-in `mutateAllItems(in sections:)` API has been added, to make it convenient to iterate over all items within a certain set of sections in a concurrent manner. - `HTMLGenerator` now executes concurrently, meaning that it can output a large number of HTML files in parallel, rather than writing all files one-by-one. - `MarkdownFileHandler` now also runs concurrently, and processes all of its Markdown files in parallel, rather than doing so sequentially. - `RSSFeedGenerator` and `PodcastFeedGenerator` now generates their respective feeds concurrently. Overall, this set of changes results in a 4x speed improvement when generating Swift by Sundell using the M1 Max chip. As part of this, Publish now requires macOS 12, to be able to use Swift Concurrency in a predictable way. macOS version 12.0 has been out for quite a while now, so it's safe to require it at this point.
1 parent f8c386f commit a0d30bc

File tree

12 files changed

+211
-89
lines changed

12 files changed

+211
-89
lines changed

Package.resolved

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// swift-tools-version:5.4
1+
// swift-tools-version:5.5
22

33
/**
44
* Publish
@@ -10,23 +10,54 @@ import PackageDescription
1010

1111
let package = Package(
1212
name: "Publish",
13+
platforms: [.macOS(.v12)],
1314
products: [
1415
.library(name: "Publish", targets: ["Publish"]),
1516
.executable(name: "publish-cli", targets: ["PublishCLI"])
1617
],
1718
dependencies: [
18-
.package(name: "Ink", url: "https://github.com/johnsundell/ink.git", from: "0.2.0"),
19-
.package(name: "Plot", url: "https://github.com/johnsundell/plot.git", from: "0.9.0"),
20-
.package(name: "Files", url: "https://github.com/johnsundell/files.git", from: "4.0.0"),
21-
.package(name: "Codextended", url: "https://github.com/johnsundell/codextended.git", from: "0.1.0"),
22-
.package(name: "ShellOut", url: "https://github.com/johnsundell/shellout.git", from: "2.3.0"),
23-
.package(name: "Sweep", url: "https://github.com/johnsundell/sweep.git", from: "0.4.0")
19+
.package(
20+
name: "Ink",
21+
url: "https://github.com/johnsundell/ink.git",
22+
from: "0.2.0"
23+
),
24+
.package(
25+
name: "Plot",
26+
url: "https://github.com/johnsundell/plot.git",
27+
from: "0.9.0"
28+
),
29+
.package(
30+
name: "Files",
31+
url: "https://github.com/johnsundell/files.git",
32+
from: "4.0.0"
33+
),
34+
.package(
35+
name: "Codextended",
36+
url: "https://github.com/johnsundell/codextended.git",
37+
from: "0.1.0"
38+
),
39+
.package(
40+
name: "ShellOut",
41+
url: "https://github.com/johnsundell/shellout.git",
42+
from: "2.3.0"
43+
),
44+
.package(
45+
name: "Sweep",
46+
url: "https://github.com/johnsundell/sweep.git",
47+
from: "0.4.0"
48+
),
49+
.package(
50+
name: "CollectionConcurrencyKit",
51+
url: "https://github.com/johnsundell/collectionConcurrencyKit.git",
52+
from: "0.1.0"
53+
)
2454
],
2555
targets: [
2656
.target(
2757
name: "Publish",
2858
dependencies: [
29-
"Ink", "Plot", "Files", "Codextended", "ShellOut", "Sweep"
59+
"Ink", "Plot", "Files", "Codextended",
60+
"ShellOut", "Sweep", "CollectionConcurrencyKit"
3061
]
3162
),
3263
.executableTarget(

Sources/Publish/API/PublishingStep.swift

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import Plot
1515
/// Steps are added when calling `Website.publish`.
1616
public struct PublishingStep<Site: Website> {
1717
/// Closure type used to define the main body of a publishing step.
18-
public typealias Closure = (inout PublishingContext<Site>) throws -> Void
18+
public typealias Closure = (inout PublishingContext<Site>) async throws -> Void
1919

2020
internal let kind: Kind
2121
internal let body: Body
@@ -60,7 +60,7 @@ public extension PublishingStep {
6060
return step
6161
case .operation(let name, let closure):
6262
return .step(named: name, kind: step.kind) { context in
63-
do { try closure(&context) }
63+
do { try await closure(&context) }
6464
catch {}
6565
}
6666
}
@@ -131,7 +131,7 @@ public extension PublishingStep {
131131
static func addMarkdownFiles(at path: Path = "Content") -> Self {
132132
step(named: "Add Markdown files from '\(path)' folder") { context in
133133
let folder = try context.folder(at: path)
134-
try MarkdownFileHandler().addMarkdownFiles(in: folder, to: &context)
134+
try await MarkdownFileHandler().addMarkdownFiles(in: folder, to: &context)
135135
}
136136
}
137137

@@ -164,21 +164,52 @@ public extension PublishingStep {
164164
matching predicate: Predicate<Item<Site>> = .any,
165165
using mutations: @escaping Mutations<Item<Site>>
166166
) -> Self {
167-
let nameSuffix = section.map { " in '\($0)'" } ?? ""
167+
mutateAllItems(
168+
in: section.map { [$0] } ?? Set(Site.SectionID.allCases),
169+
matching: predicate,
170+
using: mutations
171+
)
172+
}
168173

169-
return step(named: "Mutate items" + nameSuffix) { context in
170-
if let section = section {
171-
try context.sections[section].mutateItems(
172-
matching: predicate,
173-
using: mutations
174+
/// Mutate all items matching a predicate within a certain set of sections.
175+
/// - parameter sections: The sections to mutate all items within.
176+
/// - parameter predicate: Any predicate to filter the items using.
177+
/// - parameter mutations: The mutations to apply to each item.
178+
static func mutateAllItems(
179+
in sections: Set<Site.SectionID>,
180+
matching predicate: Predicate<Item<Site>> = .any,
181+
using mutations: @escaping Mutations<Item<Site>>
182+
) -> Self {
183+
var stepName = "Mutate all items"
184+
185+
if sections.count != Site.SectionID.allCases.count {
186+
let sectionDescription = sections
187+
.map(\.rawValue)
188+
.joined(separator: ", ")
189+
190+
stepName.append(" in \(sectionDescription)")
191+
}
192+
193+
return step(named: stepName) { context in
194+
for section in sections {
195+
try await context.sections[section].replaceItems(
196+
with: context.sections[section].items.concurrentMap { item in
197+
guard predicate.matches(item) else {
198+
return item
199+
}
200+
201+
do {
202+
var item = item
203+
try mutations(&item)
204+
return item
205+
} catch {
206+
throw ContentError(
207+
path: item.path,
208+
reason: .itemMutationFailed(error)
209+
)
210+
}
211+
}
174212
)
175-
} else {
176-
for section in context.sections.ids {
177-
try context.sections[section].mutateItems(
178-
matching: predicate,
179-
using: mutations
180-
)
181-
}
182213
}
183214
}
184215
}
@@ -343,7 +374,7 @@ public extension PublishingStep {
343374
context: context
344375
)
345376

346-
try generator.generate()
377+
try await generator.generate()
347378
}
348379
}
349380

@@ -372,7 +403,7 @@ public extension PublishingStep {
372403
date: date
373404
)
374405

375-
try generator.generate()
406+
try await generator.generate()
376407
}
377408
}
378409

@@ -420,7 +451,7 @@ public extension PublishingStep where Site.ItemMetadata: PodcastCompatibleWebsit
420451
date: date
421452
)
422453

423-
try generator.generate()
454+
try await generator.generate()
424455
}
425456
}
426457
}

Sources/Publish/API/Section.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ internal extension Section {
122122
updateLastItemModificationDateIfNeeded(to: item.date)
123123
items.append(item)
124124
}
125+
126+
mutating func replaceItems(with newItems: [Item<Site>]) {
127+
items = newItems
128+
rebuildIndexes()
129+
}
125130
}
126131

127132
private extension Section {

Sources/Publish/API/Website.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import Foundation
88
import Plot
9+
import Dispatch
910

1011
/// Protocol that all `Website.SectionID` implementations must conform to.
1112
public protocol WebsiteSectionID: Decodable, Hashable, CaseIterable, RawRepresentable where RawValue == String {}
@@ -111,7 +112,23 @@ public extension Website {
111112
originFilePath: Path("\(file)")
112113
)
113114

114-
return try pipeline.execute(for: self, at: path)
115+
let semaphore = DispatchSemaphore(value: 0)
116+
var result: Result<PublishedWebsite<Self>, Error>?
117+
let completionHandler = { result = $0 }
118+
119+
Task {
120+
do {
121+
let website = try await pipeline.execute(for: self, at: path)
122+
completionHandler(.success(website))
123+
} catch {
124+
completionHandler(.failure(error))
125+
}
126+
127+
semaphore.signal()
128+
}
129+
130+
semaphore.wait()
131+
return try result!.get()
115132
}
116133
}
117134

Sources/Publish/Internal/HTMLGenerator.swift

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,38 @@
66

77
import Plot
88
import Files
9+
import CollectionConcurrencyKit
910

1011
internal struct HTMLGenerator<Site: Website> {
1112
let theme: Theme<Site>
1213
let indentation: Indentation.Kind?
1314
let fileMode: HTMLFileMode
1415
let context: PublishingContext<Site>
1516

16-
func generate() throws {
17-
try copyThemeResources()
18-
try generateIndexHTML()
19-
try generateSectionHTML()
20-
try generatePageHTML()
21-
try generateTagHTMLIfNeeded()
17+
func generate() async throws {
18+
try await withThrowingTaskGroup(of: Void.self) { group in
19+
group.addTask { try await copyThemeResources() }
20+
group.addTask { try generateIndexHTML() }
21+
group.addTask { try await generateSectionHTML() }
22+
group.addTask { try await generatePageHTML() }
23+
group.addTask { try await generateTagHTMLIfNeeded() }
24+
25+
// Throw any errors generated by the above set of operations:
26+
for try await _ in group {}
27+
}
2228
}
2329
}
2430

2531
private extension HTMLGenerator {
26-
func copyThemeResources() throws {
32+
func copyThemeResources() async throws {
2733
guard !theme.resourcePaths.isEmpty else {
2834
return
2935
}
3036

3137
let creationFile = try File(path: theme.creationPath.string)
3238
let packageFolder = try creationFile.resolveSwiftPackageFolder()
3339

34-
for path in theme.resourcePaths {
40+
try await theme.resourcePaths.concurrentForEach { path in
3541
do {
3642
let file = try packageFolder.file(at: path.string)
3743
try context.copyFileToOutput(file, targetFolderPath: nil)
@@ -51,16 +57,16 @@ private extension HTMLGenerator {
5157
try indexFile.write(html.render(indentedBy: indentation))
5258
}
5359

54-
func generateSectionHTML() throws {
55-
for section in context.sections {
60+
func generateSectionHTML() async throws {
61+
try await context.sections.concurrentForEach { section in
5662
try outputHTML(
5763
for: section,
5864
indentedBy: indentation,
5965
using: theme.makeSectionHTML,
6066
fileMode: .foldersAndIndexFiles
6167
)
62-
63-
for item in section.items {
68+
69+
try await section.items.concurrentForEach { item in
6470
try outputHTML(
6571
for: item,
6672
indentedBy: indentation,
@@ -71,8 +77,8 @@ private extension HTMLGenerator {
7177
}
7278
}
7379

74-
func generatePageHTML() throws {
75-
for page in context.pages.values {
80+
func generatePageHTML() async throws {
81+
try await context.pages.values.concurrentForEach { page in
7682
try outputHTML(
7783
for: page,
7884
indentedBy: indentation,
@@ -82,7 +88,7 @@ private extension HTMLGenerator {
8288
}
8389
}
8490

85-
func generateTagHTMLIfNeeded() throws {
91+
func generateTagHTMLIfNeeded() async throws {
8692
guard let config = context.site.tagHTMLConfig else {
8793
return
8894
}
@@ -99,7 +105,7 @@ private extension HTMLGenerator {
99105
try listFile.write(listHTML.render(indentedBy: indentation))
100106
}
101107

102-
for tag in context.allTags {
108+
try await context.allTags.concurrentForEach { tag in
103109
let detailsPath = context.site.path(for: tag)
104110
let detailsContent = config.detailsContentResolver(tag)
105111

@@ -110,7 +116,7 @@ private extension HTMLGenerator {
110116
)
111117

112118
guard let detailsHTML = try theme.makeTagDetailsHTML(detailsPage, context) else {
113-
continue
119+
return
114120
}
115121

116122
try outputHTML(

0 commit comments

Comments
 (0)