Skip to content

SOLR-18212: Migrate PackageAPI endpoints from @EndPoint/@Command to JAX-RS - #4178

Merged
epugh merged 39 commits into
apache:mainfrom
epugh:copilot/migrate-packageapi-to-jax-rs
Nov 10, 2026
Merged

epugh merged 39 commits into
apache:mainfrom
epugh:copilot/migrate-packageapi-to-jax-rs

Conversation

@epugh

@epugh epugh commented Mar 2, 2026 •

Copy link
Copy Markdown
Contributor

Migrates org.apache.solr.pkg.PackageAPI from Solr's homegrown @EndPoint/@Command dispatch to standard JAX-RS annotations, consistent with other V2 APIs (ClusterFileStore, ZookeeperRead, ClusterProperty).

This has a pretty hefty update to the patterns, but I thought it was better to do it now while v2 api's are "experimental".

New REST endpoints

The command-based POST pattern is replaced with RESTful routes. Note the collection is now plural (/cluster/packages), matching the v2 API path conventions used elsewhere:

Old New
GET /cluster/package GET /cluster/packages
GET /cluster/package/{name} GET /cluster/packages/{name} (now returns 404 if the package doesn't exist, instead of silently returning an empty result)
POST /cluster/package {"add": {"package":"p","version":"v","files":[...]}} POST /cluster/packages/{name}/versions
POST /cluster/package {"delete": {"pkg":"p","version":"v"}} DELETE /cluster/packages/{name}/versions/{version}
POST /cluster/package {"refresh": "name"} POST /cluster/packages/{name}/refresh
(internal only, used by refresh for node-local reload) POST /cluster/packages/{name}/refresh?localOnly=true
(internal only, used to make a node catch up to a ZK version) POST /cluster/packages/sync?expectedVersion=N

The two internal-only rows replace an earlier version of this PR that smuggled the same inter-node signals onto GET /cluster/packages via refreshPackage/expectedVersion query params — a GET request shouldn't have side effects. localOnly mirrors the existing localDelete param on ClusterFileStoreApis.deleteFile.

Key structural changes

  • PackageAPI — new JAX-RS implementation (extends JerseyResource implements PackageApis); replaces the old Edit/Read inner classes (~220 lines removed)
  • PackageStore — renamed from the old PackageAPI; now clearly a ZK data/state holder (Packages, PkgVersion, ZK watcher), not an endpoint class. THIS WAS A BIG REFACTORING
  • solr/api module — new PackageApis.java (JAX-RS interface), AddPackageVersionRequestBody.java, and two response types instead of one: ListPackagesResponse (the GET /cluster/packages collection response, map-keyed by package name) and GetPackageResponse (the GET /cluster/packages/{name} single-resource response, a flat object for just that package) — matching the List<Resource>Response/Get<Resource>Response naming and shape convention used by ListAliasesResponse/GetAliasByNameResponse and similar pairs elsewhere in the v2 API. OAS generation produces PackageApi SolrJ request classes automatically.
  • PackageAPITest — integration test using SolrCloudTestCase (ZK required) with generated PackageApi SolrJ request classes
  • TestPackages — updated to use new endpoints; error path changed from /details[0]/errorMessages[0] to /msg
  • package-manager-internals.adoc — updated endpoint reference and curl examples
  • dev-docs/apis-v2-conventions.adoc — added a new "Listing vs. Single-Resource Responses" section documenting the List<Resource>Response/Get<Resource>Response convention, since this PR is the first to need it spelled out explicitly

Copilot AI and others added 8 commits March 1, 2026 13:42
- Replace PackagePayload.AddVersion with AddPackageVersionRequestBody
- Move package name from body to URL: POST /cluster/package/{name}/versions
- Replace delete command pattern with DELETE /cluster/package/{name}/versions/{version}
- Replace refresh command with POST /cluster/package/{name}/refresh
- Update errPath in testAPI from /details[0]/errorMessages[0] to /msg
- Remove all add.pkg assignments (pkg now lives in URL path)
- Replace add.pkg references in verifyComponent() with literal package name strings
- Update import: remove PackagePayload, add AddPackageVersionRequestBody (sorted)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: epugh <22395+epugh@users.noreply.github.com>
…sponse.error field

Co-authored-by: epugh <22395+epugh@users.noreply.github.com>
Co-authored-by: epugh <22395+epugh@users.noreply.github.com>
Co-authored-by: epugh <22395+epugh@users.noreply.github.com>
@epugh
epugh marked this pull request as ready for review March 2, 2026 19:36
@gerlowskija

Copy link
Copy Markdown
Contributor

Is there a JIRA ticket associated with this change @epugh?

@epugh

epugh commented Apr 3, 2026

Copy link
Copy Markdown
Contributor Author

Is there a JIRA ticket associated with this change @epugh?

No, because I viewed it as "just a refactoring and no one will notice....", and that I was hoping there were no debates that would require a JIRA to hash out!

authors:
- name: Eric Pugh
links:
- name: PR#4178

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[0] Probably worth having a JIRA reference for this guy, even if it's just a tie-back to one of the v2 umbrella tickets? It's changing APIs and stuff, so worth tracking in more than just the PR description I'd imagine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

DOne

public int hashCode() {
return Objects.hash(version);
}
final var request =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Q] Is there a reason you're not using the auto-generated SolrJ type here? We should have one now that the API is JAX-RS!

If this was an intentional decision made to prevent scope-creep, I'm fine with that. Just making sure it's not oversight.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

so many places to update... I suspect in the future we need to do a "Check each GSR for fitness" check ;-)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Like I said, I think not doing these bits in the same PR is a totally valid choice. I don't want to add extra steps that'd apply to all these PRs. But since the lines were changed in this PR already I wanted to check that it was an intentional choice is all 🤷

@epugh epugh changed the title Migrate PackageAPI endpoints from @EndPoint/@Command to JAX-RS SOLR-18212: Migrate PackageAPI endpoints from @EndPoint/@Command to JAX-RS Apr 25, 2026
@epugh

epugh commented Apr 25, 2026 •

Copy link
Copy Markdown
Contributor Author

@gerlowskija I think I am ready for another review. I renamed PackageAPI to ClusterPackage to not conflict with PackageApis, but not married to that name! I am updating the spreadsheet.

@jaykay12

Copy link
Copy Markdown
Contributor

@epugh I am planning to review this PR, can you make this one ready for review by rebasing with latest main?

@epugh

epugh commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@jaykay12 I added you as a collaborate to my epugh/solr... which I think lets you push to this repo? I hope this makes it easier as I have a couple of in progress ones you may wan tot pick up!

epugh and others added 7 commits October 28, 2026 08:29
…n the Package v2 API

- Move the inter-node refresh/sync fan-out off GET /cluster/packages (which
  was mutating state via refreshPackage/expectedVersion query params) onto
  POST endpoints: a localOnly flag on the existing /refresh action (mirroring
  ClusterFileStoreApis.deleteFile's localDelete), and a new /sync action.
- getPackage() now returns 404 for an unknown package, matching
  deletePackageVersion/refreshPackage's existing NOT_FOUND behavior.
- Split the single-package GET response into its own flat shape
  (GetPackageResponse) instead of reusing the list endpoint's map-shaped
  response filtered to one entry, and renamed both response classes to match
  the List<Resource>Response / Get<Resource>Response convention used
  elsewhere (aliases, cluster properties, schema fields).
- Extracted the duplicated node-broadcast loop in ClusterPackage into a
  shared helper.
- Documented the List/Get response-naming convention in
  dev-docs/apis-v2-conventions.adoc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kzScQszwRcG4S7kA5Pa8b
PackageManager's deploy --update and undeploy flows were still POSTing a
v1-style JSON command body ({"refresh": "name"}) to /api/cluster/packages,
left over from before the JAX-RS migration. The new endpoint doesn't accept
a body at all - it's POST /cluster/packages/{name}/refresh - so these calls
were silently broken against the migrated server. Switch both call sites to
the generated PackageApi.RefreshPackage client, matching how
DeletePackageVersion/AddPackageVersion were already migrated elsewhere in
this file. Removes the now-unused PackageUtils.PACKAGE_PATH constant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kzScQszwRcG4S7kA5Pa8b
…kage API shape

testPayload()'s @EndPoint/@command fixture (path /cluster/package, an
AddVersion bean with pkg/version/files) mirrored the now-deleted v1 Package
API almost exactly, which reads as leftover Package API code rather than a
generic framework test. Rename to /foos/FooVersion/myfoo so it's obviously
just a test fixture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kzScQszwRcG4S7kA5Pa8b
ReferenceEquality flagged schemas[0] != schemas[1] as a likely mistake, but
the test intentionally checks whether the core picked up a new IndexSchema
instance after the package version bump - identity, not value equality, is
what's being verified. Suppress with the same pattern used elsewhere in the
codebase for intentional identity checks (SolrConfig.java,
CachingDirectoryFactory.java).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kzScQszwRcG4S7kA5Pa8b
…sterPackage

addPackageVersion and deletePackageVersion each inlined the same "deserialize
packages.json bytes, fall back to a fresh Packages on missing/invalid data,
then copy()" block, and the same isEnabled()/BAD_REQUEST guard. Extracted
both into PackageStore (parseForUpdate, requireEnabled) since they operate on
PackageStore's own data/mapper, and call them from both atomicUpdate lambdas.

Also noted, via a one-line comment, why PackageManager's undeploy refresh
call site keeps a broader catch(Exception) than its sibling deploy call site
(it still wraps a postJsonToSolr call that declares throws Exception).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kzScQszwRcG4S7kA5Pa8b
@epugh

epugh commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

@jaykay12 would love your review! I think this is ready for merging.

@jaykay12 jaykay12 left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let me know if this minor plan change makes sense?

Ask is to just isolate the { renaming of PackageAPI -> PackageStore } in a follow-up PR.

/**
* Holds package data loaded from ZooKeeper ({@code /solr/packages.json}) and manages ZK watchers.
*/
public class PackageStore {

@jaykay12 jaykay12 Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this class is majorly (PackageAPI - {code no longer needed}), please correct me if i am wrong.

[suggestion] should we be splitting up PRs in those case, so that review stays easier & any unintentional change gets caught.

This PR could have been just pure migration & removal of deprecated stuff from PackageAPI class. In subsequent follow-up this renaming could have been attempted?

This is my pure thought, with this minor change, the PR size would have reduced by atleast 8-9 files.

@Override
@PermissionName(PACKAGE_EDIT_PERM)
public SolrJerseyResponse addPackageVersion(
String packageName, AddPackageVersionRequestBody requestBody) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should the core logic related to addVersion & deleteVersion be kept in PackageStore or PackageAPI for now (if earlier comment is chosen to be addressed)

that way, this class remains as thin adapter only.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, done in 7503001 -- moved the ZK atomicUpdate orchestration for both addVersion and deleteVersion into PackageStore. ClusterPackage now just validates/delegates/broadcasts:

Integer newVersion = packageStore.addVersion(packageName, requestBody);
if (newVersion != null) {
  notifyAllNodesToSync(newVersion);
}

addVersion() returns the new znode version (or null if the ZK update failed), preserving the original behavior of only notifying other nodes/refreshing local config when the update actually succeeded.

return response;
}

private void syncToVersion(PackageStore packageStore, int expectedVersion) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should this be kept as package private function inside PackageStore?

[reason]
this function mutates - packageStore.pkgs, ideally we can keep that ownership from within packageStore? if this function moves inside packageStore, we can esure package.pkgs is private to that class & no one changes its state from outside other than its own functions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, moved in 7503001. syncToVersion is now a package-private instance method on PackageStore operating on its own pkgs field; ClusterPackage no longer assigns to packageStore.pkgs anywhere (confirmed -- grepped for it). This also makes it consistent with PackageStore's existing refreshPackages() watch handler, which already pairs a pkgs mutation with an immediate refreshPackageConf() call the same way.

solrClient, PackageUtils.PACKAGE_PATH, "{\"refresh\": \"" + packageName + "\"}");
} catch (Exception ex) {
new PackageApi.RefreshPackage(packageName).process(solrClient);
} catch (Exception ex) { // postJsonToSolr above declares a plain throws Exception

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this code comment still valid?

/** Response for the get-a-specific-package API. */
public class GetPackageResponse extends SolrJerseyResponse {

@JsonProperty("result")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[info] why the key name as "result", why not just "packageInfo" here?

/** Response for the package listing API. */
public class ListPackagesResponse extends SolrJerseyResponse {

@JsonProperty("result")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[question] why not "packages" as the key?

@JsonProperty(required = true)
public List<String> files;
@JsonProperty("version")
@Schema(description = "The version string for this package version.", required = true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

my local IntelliJ showed this warning.

Image

please see, if applicable here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

thanks, going to look....

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed via the compiler (-Xlint:deprecation):

AddPackageVersionRequestBody.java:27: warning: [deprecation] required() in Schema has been deprecated
AddPackageVersionRequestBody.java:33: warning: [deprecation] required() in Schema has been deprecated

@Schema(required = true) is deprecated in favor of @Schema(requiredMode = Schema.RequiredMode.REQUIRED). That said, this isn't something to fix just here -- 19 other files under solr/api/src/java/org/apache/solr/client/api/model/ use the exact same required = true attribute, so it's a pre-existing, codebase-wide pattern rather than something this PR introduced. Rather than fix it inconsistently in this one new file, I'll leave it as a follow-up to do as its own small cleanup PR across the whole module.

}
if (packageStore.pkgs.znodeVersion >= expectedVersion) {
if (origVersion < packageStore.pkgs.znodeVersion) {
coreContainer.getPackageLoader().refreshPackageConf();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Per dev-docs/apis-v2-conventions.adoc's HTTP Methods section: "GET - used for non-mutating (i.e. 'read only') requests... Do not use for requests like queries that change state."

listPackages()/getPackage() are GET endpoints, but when called with expectedVersion they call syncToVersion(), which can reach this refreshPackageConf() call -- reloading plugin instances on this node. That's more than a pure read: it can swap out live plugin instances for concurrent requests being served on this node.

This is a defensible gray area (it's catching the node up to state already committed elsewhere in ZK, not originating a new mutation), but it's worth either a comment explaining the tradeoff, or considering whether the GET path should only re-read from ZK (without the full refreshPackageConf() reload) and leave the reload to the write paths / the explicit sync command.

Boolean localOnly);

@POST
@Path("/sync")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Per dev-docs/apis-v2-conventions.adoc's "Unproxied APIs" section: paths like /api/node/key exist specifically to mark operations that only affect the receiving node and must never be proxied. syncPackages() fits that exactly -- it only ever touches the local PackageStore (see ClusterPackage.syncToVersion), and is invoked exclusively via broadcastToLiveNodes()/processWithBaseUrl() targeting one specific node's base URL. The @Operation summary here says as much: "Used internally for inter-node fan-out after a package change; not normally invoked directly."

Recommend POST /api/node/packages/sync instead of /cluster/packages/sync, following the existing /node/{resource} pattern (NodeSystemInfoApi → /node/system, NodeHealthApi → /node/health, and especially GetNodeCommandStatusApi → /node/commands/, the closest precedent as another receiving-node-only, not-cluster-tracked concept) combined with the "command API" convention already used elsewhere in this PR (refreshPackage → .../refresh): packages as the resource, sync as the appended command verb. Only this one endpoint would need to move -- listPackages/getPackage are genuinely cluster-wide, proxy-friendly reads and should stay under /cluster/packages.

epugh and others added 2 commits November 10, 2026 06:51
jaykay12 asked on PR apache#4178 whether this comment was still valid. Checked
git blame: it predates this migration entirely (SOLR-13662, 2021) and was
just carried over as incidental diff context. The underlying question
was never acted on and isn't related to this PR's scope, so removing it
rather than leaving a stale rhetorical TODO in migrated code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RwsEje8u9apx3SRzissZm
Addresses jaykay12's review comments on PR apache#4178:
- addPackageVersion()/deletePackageVersion() embedded ~40 lines of ZK
  atomicUpdate orchestration directly in ClusterPackage, rather than
  delegating to PackageStore, which already owns `pkgs` and the relevant
  ZK helpers (parseForUpdate, handleZkErr). Moved that logic into
  PackageStore.addVersion()/deleteVersion(), keeping ClusterPackage a
  thin adapter: validate inputs, delegate, broadcast to other nodes.
- syncToVersion() mutated packageStore.pkgs from outside the class that
  owns it (pkgs has no visibility modifier, so same-package access let
  ClusterPackage write it directly). Moved it into PackageStore as a
  package-private instance method operating on its own `pkgs` field.
  ClusterPackage no longer assigns to packageStore.pkgs anywhere.

addVersion() now returns the new znode version (or null if the ZK update
failed), preserving the original behavior of only notifying other nodes
and refreshing local package config when the update actually succeeded.
This also makes PackageStore.addVersion()/syncToVersion() consistent with
its own existing refreshPackages() watch handler, which already pairs
a `pkgs` mutation with an immediate refreshPackageConf() call.

Verified via 6 repeated runs of PackageToolTest/PackageToolPicocliTest
(which exercise cross-node package deployment) plus ClusterPackageTest,
TestPackages, PackageSchemaReloadTest, ConfigsetPinnedPackageVersionTest,
PackageStoreSchemaPluginsTest, and TestContainerPlugin -- all pass. One
transient failure during verification reproduced identically on the
unmodified baseline, confirming it's pre-existing test flakiness (each
node's syncToVersion polls ZK independently, so this reorders only when
*this* node refreshes relative to notifying *other* nodes, which doesn't
affect their own sync timing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RwsEje8u9apx3SRzissZm
@epugh

epugh commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Hey @jaykay12 your feedback was super valuable!!!!! I rethought things based on your input!

epugh and others added 2 commits November 10, 2026 07:37
Flatten ListPackagesResponse and GetPackageResponse so "packages"/
"znodeVersion"/"name"/"versions" are top-level fields instead of being
nested under a generic "result" wrapper, matching the existing
ListCollectionsResponse convention. Updates ClusterPackageTest and the
ref guide's example JSON accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RwsEje8u9apx3SRzissZm
TestContainerPlugin, TestPackages, and PackageSchemaReloadTest asserted
on the hardcoded JSON path ":result:packages:..." against GET
/cluster/packages, which broke once ListPackagesResponse dropped its
"result" wrapper. Update the expected paths to ":packages:...".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RwsEje8u9apx3SRzissZm
@epugh
epugh merged commit 5996f27 into apache:main Nov 10, 2026
7 of 8 checks passed
@epugh epugh added this to the 10.x milestone Nov 10, 2026
dsmiley pushed a commit that referenced this pull request Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants