Skip to content

Commit a6fc1ec

Browse files
committed
Use Swift Concurrency for convert action rendering
1 parent 0e0c8f3 commit a6fc1ec

File tree

5 files changed

+101
-130
lines changed

5 files changed

+101
-130
lines changed

Sources/SwiftDocC/Infrastructure/ConvertActionConverter.swift

Lines changed: 86 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,13 @@ package enum ConvertActionConverter {
2929
/// - sourceRepository: The source repository where the documentation's sources are hosted.
3030
/// - emitDigest: Whether the conversion should pass additional metadata output––such as linkable entities information, indexing information, or asset references by asset type––to the consumer.
3131
/// - documentationCoverageOptions: The level of experimental documentation coverage information that the conversion should pass to the consumer.
32-
/// - Returns: A list of problems that occurred during the conversion (excluding the problems that the context already encountered).
3332
package static func convert(
3433
context: DocumentationContext,
3534
outputConsumer: some ConvertOutputConsumer & ExternalNodeConsumer,
3635
sourceRepository: SourceRepository?,
3736
emitDigest: Bool,
3837
documentationCoverageOptions: DocumentationCoverageOptions
39-
) throws -> [Problem] {
38+
) async throws {
4039
let signposter = Self.signposter
4140

4241
defer {
@@ -54,7 +53,7 @@ package enum ConvertActionConverter {
5453
if emitDigest {
5554
try (_Deprecated(outputConsumer) as (any _DeprecatedConsumeProblemsAccess))._consume(problems: context.problems)
5655
}
57-
return []
56+
return
5857
}
5958

6059
// Precompute the render context
@@ -71,36 +70,7 @@ package enum ConvertActionConverter {
7170
renderContext: renderContext,
7271
sourceRepository: sourceRepository
7372
)
74-
75-
// Arrays to gather additional metadata if `emitDigest` is `true`.
76-
var indexingRecords = [IndexingRecord]()
77-
var linkSummaries = [LinkDestinationSummary]()
78-
var assets = [RenderReferenceType : [any RenderReference]]()
79-
var coverageInfo = [CoverageDataEntry]()
80-
let coverageFilterClosure = documentationCoverageOptions.generateFilterClosure()
81-
82-
// An inner function to gather problems for errors encountered during the conversion.
83-
//
84-
// These problems only represent unexpected thrown errors and aren't particularly user-facing.
85-
// For now we emit them as diagnostics because `DocumentationConverter.convert(outputConsumer:)` (which this replaced) used to do that.
86-
//
87-
// FIXME: In the future we could simplify this control flow by not catching these errors and turning them into diagnostics.
88-
// Since both error-level diagnostics and thrown errors fail the documentation build,
89-
// the only practical different this would have is that we stop on the first unexpected error instead of processing all pages and gathering all unexpected errors.
90-
func recordProblem(from error: any Swift.Error, in problems: inout [Problem], withIdentifier identifier: String) {
91-
let problem = Problem(diagnostic: Diagnostic(
92-
severity: .error,
93-
identifier: "org.swift.docc.documentation-converter.\(identifier)",
94-
summary: error.localizedDescription
95-
), possibleSolutions: [])
96-
97-
context.diagnosticEngine.emit(problem)
98-
problems.append(problem)
99-
}
100-
101-
let resultsSyncQueue = DispatchQueue(label: "Convert Serial Queue", qos: .unspecified, attributes: [])
102-
let resultsGroup = DispatchGroup()
103-
73+
10474
// Consume external links and add them into the sidebar.
10575
for externalLink in context.externalCache {
10676
// Here we're associating the external node with the **current** bundle's bundle ID.
@@ -112,112 +82,112 @@ package enum ConvertActionConverter {
11282

11383
let renderSignpostHandle = signposter.beginInterval("Render", id: signposter.makeSignpostID(), "Render \(context.knownPages.count) pages")
11484

115-
var conversionProblems: [Problem] = context.knownPages.concurrentPerform { identifier, results in
116-
// If cancelled skip all concurrent conversion work in this block.
117-
guard !Task.isCancelled else { return }
85+
// Render all pages and gather their supplementary "digest" information if enabled.
86+
let supplementaryRenderInfo = try await withThrowingTaskGroup(of: SupplementaryRenderInformation.self) { taskGroup in
87+
let coverageFilterClosure = documentationCoverageOptions.generateFilterClosure()
88+
// Iterate over all the known pages in chunks
89+
var remaining = context.knownPages[...]
11890

119-
// Wrap JSON encoding in an autorelease pool to avoid retaining the autoreleased ObjC objects returned by `JSONSerialization`
120-
autoreleasepool {
121-
do {
122-
let entity = try context.entity(with: identifier)
123-
124-
guard let renderNode = converter.renderNode(for: entity) else {
125-
// No render node was produced for this entity, so just skip it.
126-
return
127-
}
91+
let numberOfBatches = ProcessInfo.processInfo.processorCount * 4
92+
let numberOfElementsPerTask = Int(Double(remaining.count) / Double(numberOfBatches) + 1)
93+
94+
while !remaining.isEmpty {
95+
let slice = remaining.prefix(numberOfElementsPerTask)
96+
remaining = remaining.dropFirst(numberOfElementsPerTask)
97+
98+
// Start work of one slice of the known pages
99+
taskGroup.addTask {
100+
var supplementaryRenderInfo = SupplementaryRenderInformation()
128101

129-
try outputConsumer.consume(renderNode: renderNode)
102+
for identifier in slice {
103+
try autoreleasepool {
104+
let entity = try context.entity(with: identifier)
105+
106+
guard let renderNode = converter.renderNode(for: entity) else {
107+
// No render node was produced for this entity, so just skip it.
108+
return
109+
}
110+
111+
try outputConsumer.consume(renderNode: renderNode)
130112

131-
switch documentationCoverageOptions.level {
132-
case .detailed, .brief:
133-
let coverageEntry = try CoverageDataEntry(
134-
documentationNode: entity,
135-
renderNode: renderNode,
136-
context: context
137-
)
138-
if coverageFilterClosure(coverageEntry) {
139-
resultsGroup.async(queue: resultsSyncQueue) {
140-
coverageInfo.append(coverageEntry)
113+
switch documentationCoverageOptions.level {
114+
case .detailed, .brief:
115+
let coverageEntry = try CoverageDataEntry(
116+
documentationNode: entity,
117+
renderNode: renderNode,
118+
context: context
119+
)
120+
if coverageFilterClosure(coverageEntry) {
121+
supplementaryRenderInfo.coverageInfo.append(coverageEntry)
122+
}
123+
case .none:
124+
break
125+
}
126+
127+
if emitDigest {
128+
let nodeLinkSummaries = entity.externallyLinkableElementSummaries(context: context, renderNode: renderNode, includeTaskGroups: true)
129+
let nodeIndexingRecords = try renderNode.indexingRecords(onPage: identifier)
130+
131+
supplementaryRenderInfo.assets.merge(renderNode.assetReferences, uniquingKeysWith: +)
132+
supplementaryRenderInfo.linkSummaries.append(contentsOf: nodeLinkSummaries)
133+
supplementaryRenderInfo.indexingRecords.append(contentsOf: nodeIndexingRecords)
134+
} else if FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled {
135+
let nodeLinkSummaries = entity.externallyLinkableElementSummaries(context: context, renderNode: renderNode, includeTaskGroups: false)
136+
137+
supplementaryRenderInfo.linkSummaries.append(contentsOf: nodeLinkSummaries)
141138
}
142139
}
143-
case .none:
144-
break
145140
}
146141

147-
if emitDigest {
148-
let nodeLinkSummaries = entity.externallyLinkableElementSummaries(context: context, renderNode: renderNode, includeTaskGroups: true)
149-
let nodeIndexingRecords = try renderNode.indexingRecords(onPage: identifier)
150-
151-
resultsGroup.async(queue: resultsSyncQueue) {
152-
assets.merge(renderNode.assetReferences, uniquingKeysWith: +)
153-
linkSummaries.append(contentsOf: nodeLinkSummaries)
154-
indexingRecords.append(contentsOf: nodeIndexingRecords)
155-
}
156-
} else if FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled {
157-
let nodeLinkSummaries = entity.externallyLinkableElementSummaries(context: context, renderNode: renderNode, includeTaskGroups: false)
158-
159-
resultsGroup.async(queue: resultsSyncQueue) {
160-
linkSummaries.append(contentsOf: nodeLinkSummaries)
161-
}
162-
}
163-
} catch {
164-
recordProblem(from: error, in: &results, withIdentifier: "render-node")
142+
return supplementaryRenderInfo
165143
}
166144
}
145+
146+
var aggregateSupplementaryRenderInfo = SupplementaryRenderInformation()
147+
148+
for try await partialInfo in taskGroup {
149+
aggregateSupplementaryRenderInfo.assets.merge(partialInfo.assets, uniquingKeysWith: +)
150+
aggregateSupplementaryRenderInfo.linkSummaries.append(contentsOf: partialInfo.linkSummaries)
151+
aggregateSupplementaryRenderInfo.indexingRecords.append(contentsOf: partialInfo.indexingRecords)
152+
aggregateSupplementaryRenderInfo.coverageInfo.append(contentsOf: partialInfo.coverageInfo)
153+
}
154+
155+
return aggregateSupplementaryRenderInfo
167156
}
168157

169-
// Wait for any concurrent updates to complete.
170-
resultsGroup.wait()
171-
172158
signposter.endInterval("Render", renderSignpostHandle)
173159

174-
guard !Task.isCancelled else { return [] }
160+
guard !Task.isCancelled else { return }
175161

176162
// Write various metadata
177163
if emitDigest {
178-
signposter.withIntervalSignpost("Emit digest", id: signposter.makeSignpostID()) {
179-
do {
180-
try outputConsumer.consume(linkableElementSummaries: linkSummaries)
181-
try outputConsumer.consume(indexingRecords: indexingRecords)
182-
try outputConsumer.consume(assets: assets)
183-
} catch {
184-
recordProblem(from: error, in: &conversionProblems, withIdentifier: "metadata")
185-
}
164+
try signposter.withIntervalSignpost("Emit digest", id: signposter.makeSignpostID()) {
165+
try outputConsumer.consume(linkableElementSummaries: supplementaryRenderInfo.linkSummaries)
166+
try outputConsumer.consume(indexingRecords: supplementaryRenderInfo.indexingRecords)
167+
try outputConsumer.consume(assets: supplementaryRenderInfo.assets)
186168
}
187169
}
188170

189171
if FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled {
190-
signposter.withIntervalSignpost("Serialize link hierarchy", id: signposter.makeSignpostID()) {
191-
do {
192-
let serializableLinkInformation = try context.linkResolver.localResolver.prepareForSerialization(bundleID: context.inputs.id)
193-
try outputConsumer.consume(linkResolutionInformation: serializableLinkInformation)
194-
195-
if !emitDigest {
196-
try outputConsumer.consume(linkableElementSummaries: linkSummaries)
197-
}
198-
} catch {
199-
recordProblem(from: error, in: &conversionProblems, withIdentifier: "link-resolver")
172+
try signposter.withIntervalSignpost("Serialize link hierarchy", id: signposter.makeSignpostID()) {
173+
let serializableLinkInformation = try context.linkResolver.localResolver.prepareForSerialization(bundleID: context.inputs.id)
174+
try outputConsumer.consume(linkResolutionInformation: serializableLinkInformation)
175+
176+
if !emitDigest {
177+
try outputConsumer.consume(linkableElementSummaries: supplementaryRenderInfo.linkSummaries)
200178
}
201179
}
202180
}
203181

204182
if emitDigest {
205-
signposter.withIntervalSignpost("Emit digest", id: signposter.makeSignpostID()) {
206-
do {
207-
try (_Deprecated(outputConsumer) as (any _DeprecatedConsumeProblemsAccess))._consume(problems: context.problems + conversionProblems)
208-
} catch {
209-
recordProblem(from: error, in: &conversionProblems, withIdentifier: "problems")
210-
}
183+
try signposter.withIntervalSignpost("Emit digest", id: signposter.makeSignpostID()) {
184+
try (_Deprecated(outputConsumer) as (any _DeprecatedConsumeProblemsAccess))._consume(problems: context.problems)
211185
}
212186
}
213187

214188
switch documentationCoverageOptions.level {
215189
case .detailed, .brief:
216-
do {
217-
try outputConsumer.consume(documentationCoverageInfo: coverageInfo)
218-
} catch {
219-
recordProblem(from: error, in: &conversionProblems, withIdentifier: "coverage")
220-
}
190+
try outputConsumer.consume(documentationCoverageInfo: supplementaryRenderInfo.coverageInfo)
221191
case .none:
222192
break
223193
}
@@ -232,7 +202,12 @@ package enum ConvertActionConverter {
232202
benchmark(add: Benchmark.ExternalTopicsHash(context: context))
233203
// Log the peak memory.
234204
benchmark(add: Benchmark.PeakMemory())
235-
236-
return conversionProblems
237205
}
238206
}
207+
208+
private struct SupplementaryRenderInformation {
209+
var indexingRecords = [IndexingRecord]()
210+
var linkSummaries = [LinkDestinationSummary]()
211+
var assets = [RenderReferenceType : [any RenderReference]]()
212+
var coverageInfo = [CoverageDataEntry]()
213+
}

Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -313,19 +313,16 @@ public struct ConvertAction: AsyncAction {
313313
}
314314
}
315315

316-
let analysisProblems: [Problem]
317-
let conversionProblems: [Problem]
318316
do {
319-
conversionProblems = try signposter.withIntervalSignpost("Process") {
320-
try ConvertActionConverter.convert(
321-
context: context,
322-
outputConsumer: outputConsumer,
323-
sourceRepository: sourceRepository,
324-
emitDigest: emitDigest,
325-
documentationCoverageOptions: documentationCoverageOptions
326-
)
327-
}
328-
analysisProblems = context.problems
317+
let processInterval = signposter.beginInterval("Process", id: signposter.makeSignpostID())
318+
try await ConvertActionConverter.convert(
319+
context: context,
320+
outputConsumer: outputConsumer,
321+
sourceRepository: sourceRepository,
322+
emitDigest: emitDigest,
323+
documentationCoverageOptions: documentationCoverageOptions
324+
)
325+
signposter.endInterval("Process", processInterval)
329326
} catch {
330327
if emitDigest {
331328
let problem = Problem(description: (error as? (any DescribedError))?.errorDescription ?? error.localizedDescription, source: nil)
@@ -335,7 +332,7 @@ public struct ConvertAction: AsyncAction {
335332
throw error
336333
}
337334

338-
var didEncounterError = analysisProblems.containsErrors || conversionProblems.containsErrors
335+
var didEncounterError = context.problems.containsErrors
339336
let hasTutorial = context.knownPages.contains(where: {
340337
guard let kind = try? context.entity(with: $0).kind else { return false }
341338
return kind == .tutorial || kind == .tutorialArticle

Tests/SwiftDocCTests/DeprecatedDiagnosticsDigestWarningTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import SwiftDocC
1313
import SwiftDocCTestUtilities
1414
import XCTest
1515

16-
// THIS SHOULD BE REMOVED, RIGHT?!
16+
// FIXME: Remove this after 6.3 is released
1717
class DeprecatedDiagnosticsDigestWarningTests: XCTestCase {
1818
func testNoDeprecationWarningWhenThereAreNoOtherWarnings() async throws {
1919
let catalog = Folder(name: "unit-test.docc", content: [
@@ -26,7 +26,7 @@ class DeprecatedDiagnosticsDigestWarningTests: XCTestCase {
2626
let (_, context) = try await loadBundle(catalog: catalog)
2727
let outputConsumer = TestOutputConsumer()
2828

29-
_ = try ConvertActionConverter.convert(
29+
try await ConvertActionConverter.convert(
3030
context: context,
3131
outputConsumer: outputConsumer,
3232
sourceRepository: nil,
@@ -50,7 +50,7 @@ class DeprecatedDiagnosticsDigestWarningTests: XCTestCase {
5050
let (_, context) = try await loadBundle(catalog: catalog)
5151
let outputConsumer = TestOutputConsumer()
5252

53-
_ = try ConvertActionConverter.convert(
53+
try await ConvertActionConverter.convert(
5454
context: context,
5555
outputConsumer: outputConsumer,
5656
sourceRepository: nil,

Tests/SwiftDocCTests/TestRenderNodeOutputConsumer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ extension XCTestCase {
9595
)
9696
let outputConsumer = TestRenderNodeOutputConsumer()
9797

98-
_ = try ConvertActionConverter.convert(
98+
try await ConvertActionConverter.convert(
9999
context: context,
100100
outputConsumer: outputConsumer,
101101
sourceRepository: sourceRepository,

Tests/SwiftDocCUtilitiesTests/MergeActionTests.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -940,8 +940,7 @@ class MergeActionTests: XCTestCase {
940940

941941
let outputConsumer = ConvertFileWritingConsumer(targetFolder: outputPath, bundleRootFolder: catalogDir, fileManager: fileSystem, context: context, indexer: indexer, transformForStaticHostingIndexHTML: nil, bundleID: inputs.id)
942942

943-
let convertProblems = try ConvertActionConverter.convert(context: context, outputConsumer: outputConsumer, sourceRepository: nil, emitDigest: false, documentationCoverageOptions: .noCoverage)
944-
XCTAssert(convertProblems.isEmpty, "Unexpected problems: \(context.problems.map(\.diagnostic.summary).joined(separator: "\n"))", file: file, line: line)
943+
try await ConvertActionConverter.convert(context: context, outputConsumer: outputConsumer, sourceRepository: nil, emitDigest: false, documentationCoverageOptions: .noCoverage)
945944

946945
let navigatorProblems = indexer.finalize(emitJSON: true, emitLMDB: false)
947946
XCTAssert(navigatorProblems.isEmpty, "Unexpected problems: \(context.problems.map(\.diagnostic.summary).joined(separator: "\n"))", file: file, line: line)

0 commit comments

Comments
 (0)