Skip to content

Handle paginated registry metadata responses #8219

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

Merged

Conversation

plemarquand
Copy link
Contributor

@plemarquand plemarquand commented Jan 14, 2025

Motivation:

In 4.1 List Package Releases it is stated that a server may respond with a Link header that contains a pointer to a subsequent page of results. SwiftPM is not checking for the next link in the Link header and so if a registry returns paginated results, only the first page of versions is searched when resolving. This would result in a "version not found" error when in reality the version is present in a subsequent page of results.

Modifications:

Respect the next link in the Link header by loading the next page of results and building up a list of versions, continuing until there is no next link present in the Link header of the last result.

Result:

Registry servers that serve paginated results now have all their results read.

Issue: #8215

In [4.1 List Package
Releases](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#41-list-package-releases)
it is stated that a server may respond with a `Link` header that contains
a pointer to a subsequent page of results. SPM is not checking for this
link in the `Link` header and so if a registry returns paginated results
only the first page of versions is searched when resolving.

Respect the `next` link in the `Link` header by loading the next page of
results and building up a list of versions, continuing until there is no
`next` link present in the `Link` header of the last result.

Issue: swiftlang#8215
@plemarquand plemarquand force-pushed the paginated-repo-client-versions branch from 5266f86 to 022da0b Compare January 15, 2025 15:56
@plemarquand
Copy link
Contributor Author

@swift-ci test

@plemarquand
Copy link
Contributor Author

@swift-ci test windows

observabilityScope: ObservabilityScope,
callbackQueue: DispatchQueue,
completion: @escaping (Result<PackageMetadata, Error>) -> Void
) {
let completion = self.makeAsync(completion, on: callbackQueue)
_ = Task {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the unstructured task spawned, how does this handle cancellation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, cancellation is not handled, though was it handled previously? As far as I can tell the callbackQueue typically provided is DispatchQueue.sharedConcurrent and DispatchQueue doesn't have a nice mechanism for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps:

@available(*, noasync, message: "Use the async alternative")
public func getPackageMetadata(
    package: PackageIdentity,
    timeout: DispatchTimeInterval? = .none,
    observabilityScope: ObservabilityScope,
    callbackQueue: DispatchQueue,
    completion: @escaping (Result<PackageMetadata, Error>) -> Void
) {
    let completion = self.makeAsync(completion, on: callbackQueue)
    _ = Task {
        do {
            let result = try await self.getPackageMetadata(
                package: package,
                timeout: timeout,
                observabilityScope: observabilityScope,
                callbackQueue: callbackQueue
            )

            guard !Task.isCancelled else {
                completion(.failure(CancellationError()))
                return
            }
            completion(.success(result))
        } catch {
            guard !Task.isCancelled else {
                completion(.failure(CancellationError()))
                return
            }
            completion(.failure(error))
        }
    }
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Task.isCancelled will always be false since only structured child tasks get cancellation propagated from parent tasks. Task is an unstructured task with no parents, the only primitives for structured concurrency that propagate cancellation are async let and withTaskGroup.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, that is consistent with my understanding. Seeing as the sync version of the API didn't handle cancellation before I think its reasonable to keep that behaviour.

However you're correct that the async version should handle cancellation, and I've updated the PR accordingly.

@plemarquand
Copy link
Contributor Author

@swift-ci test

}

observabilityScope.emit(debug: "registry for \(package): \(registry)")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Where'd this observability message go?

@@ -88,12 +88,205 @@ final class RegistryClientTests: XCTestCase {
let registryClient = makeRegistryClient(configuration: configuration, httpClient: httpClient)
let metadata = try await registryClient.getPackageMetadata(package: identity)
XCTAssertEqual(metadata.versions, ["1.1.1", "1.0.0"])
XCTAssertEqual(metadata.alternateLocations!, [
XCTAssertEqual(metadata.alternateLocations, [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: There's one more happy kitten and this is less loud.


let response: LegacyHTTPClient.Response
do {
response = try await self.httpClient.get(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I wonder if there is an openapi spec for the package registries. It probably wouldn't handle pagination out of the box (an assumption). But, it might eliminate some boilerplate and make mocking with Swift datatypes instead of JSON payloads.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@czechboy0 would know if OpenAPI specs have a way to communicate pagination.

Copy link
Member

@czechboy0 czechboy0 Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I refreshed the OpenAPI doc a few months ago, and if used for codegen, it would remove a bunch of the manual networking code here: https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/registry.openapi.yaml

OpenAPI itself doesn't get as high level as providing conventions for pagination. But the generated code will give you the Link header, it's documented in the OpenAPI doc.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you adopt Swift OpenAPI Generator, I think it'd be reasonable to file an issue on us to provide the Link header parsing out of the box, so you'd get typesafe access to the individual link values. That way you could drop most of this manual parsing code.

Copy link
Member

@cmcgee1024 cmcgee1024 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks good to me. I've just added a few thoughts and questions.

@plemarquand plemarquand merged commit 8258436 into swiftlang:main Jan 30, 2025
5 checks passed
@plemarquand plemarquand deleted the paginated-repo-client-versions branch January 30, 2025 14:18
plemarquand added a commit to plemarquand/swift-package-manager that referenced this pull request Jan 30, 2025
In [4.1 List Package
Releases](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#41-list-package-releases)
it is stated that a server may respond with a `Link` header that
contains a pointer to a subsequent page of results. SwiftPM is not
checking for the `next` link in the `Link` header and so if a registry
returns paginated results, only the first page of versions is searched
when resolving. This would result in a "version not found" error when in
reality the version is present in a subsequent page of results.

Respect the `next` link in the `Link` header by loading the next page of
results and building up a list of versions, continuing until there is no
`next` link present in the `Link` header of the last result.

Registry servers that serve paginated results now have all their results
read.

Issue: swiftlang#8215
bripeticca pushed a commit to bripeticca/swift-package-manager that referenced this pull request Feb 28, 2025
In [4.1 List Package
Releases](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#41-list-package-releases)
it is stated that a server may respond with a `Link` header that
contains a pointer to a subsequent page of results. SwiftPM is not
checking for the `next` link in the `Link` header and so if a registry
returns paginated results, only the first page of versions is searched
when resolving. This would result in a "version not found" error when in
reality the version is present in a subsequent page of results.

Respect the `next` link in the `Link` header by loading the next page of
results and building up a list of versions, continuing until there is no
`next` link present in the `Link` header of the last result.

Registry servers that serve paginated results now have all their results
read.

Issue: swiftlang#8215
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants