This is an automated email from the ASF dual-hosted git repository.
janhoy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-orbit.git
The following commit(s) were added to refs/heads/main by this push:
new d8788eff Ask Solr which create-collection payload it takes (#112)
d8788eff is described below
commit d8788eff2516df0acfa15a37e90fc56740c0deab
Author: Jan Høydahl <[email protected]>
AuthorDate: Fri Oct 16 09:21:44 2026 +0200
Ask Solr which create-collection payload it takes (#112)
---
solrorbit/client.py | 39 ++++++++++++++++++++++++++++-----
tests/client_test.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 96 insertions(+), 5 deletions(-)
diff --git a/solrorbit/client.py b/solrorbit/client.py
index 73538718..3724cfe4 100644
--- a/solrorbit/client.py
+++ b/solrorbit/client.py
@@ -212,11 +212,7 @@ class SolrAdminClient:
"pullReplicas": pull_replicas,
"waitForFinalState": True,
}
- resp = self._get_session().post(
- f"{self.api_url}/collections",
- json=payload,
- timeout=self.timeout,
- )
+ resp = self._post_collection(payload)
if resp.status_code == 400:
body = self._try_parse_json(resp)
if "already exists" in str(body).lower():
@@ -227,6 +223,39 @@ class SolrAdminClient:
logger.info("Created collection '%s' (shards=%d, nrt=%d, tlog=%d,
pull=%d)",
name, num_shards, replication_factor, tlog_replicas,
pull_replicas)
+ def _post_collection(self, payload: dict):
+ """POST a create-collection body, in whichever shape this Solr takes.
+
+ Solr reworked its V2 API: newer releases take the collection's
+ properties as the request body, older ones take a command envelope,
+ ``{"create": {...}}``. Benchmarking a release line means talking to
both
+ -- a 9.0 cluster answers the newer shape with
+ ``Unknown operation 'name', available ops are [... create ...]``, which
+ is Solr telling us exactly which shape it wanted.
+
+ Asking the version instead would mean keeping a table of which release
+ changed, and being wrong about a release nobody tested. Letting Solr
+ say so costs one extra request, once, and only on the clusters that
+ need it.
+ """
+ session = self._get_session()
+ url = f"{self.api_url}/collections"
+ resp = session.post(url, json=payload, timeout=self.timeout)
+ if resp.status_code == 400 and self._wants_command_envelope(resp):
+ logger.debug("Retrying create-collection with the pre-rework V2
command envelope")
+ resp = session.post(url, json={"create": payload},
timeout=self.timeout)
+ return resp
+
+ @staticmethod
+ def _wants_command_envelope(resp) -> bool:
+ """Whether a 400 is Solr saying "that is not how I take commands"."""
+ try:
+ body = resp.json()
+ except ValueError:
+ return False
+ text = str(body).lower()
+ return "unknown operation" in text and "create" in text
+
def delete_collection(self, name: str) -> None:
"""Delete a Solr collection via DELETE /api/collections/{name}."""
resp = self._get_session().delete(
diff --git a/tests/client_test.py b/tests/client_test.py
index e99fb8b3..140cf088 100644
--- a/tests/client_test.py
+++ b/tests/client_test.py
@@ -54,3 +54,65 @@ class RequestContextManagerTests(TestCase):
self.assertAlmostEqual(top_level_duration, 0.2, delta=0.05)
# nested request should only cover nested duration
self.assertAlmostEqual(nested_duration, 0.1, delta=0.05)
+
+
+class CreateCollectionApiShapeTests(TestCase):
+ """Solr reworked its V2 API: newer releases take the collection's
+ properties as the request body, older ones take a ``{"create": {...}}``
+ envelope. Benchmarking a release line means talking to both."""
+
+ class FakeResponse:
+ def __init__(self, status_code, body=None):
+ self.status_code = status_code
+ self._body = body if body is not None else {}
+ self.text = str(self._body)
+ self.ok = status_code < 400
+
+ def json(self):
+ return self._body
+
+ class FakeSession:
+ def __init__(self, reject_first):
+ self.reject_first = reject_first
+ self.posts = []
+
+ def post(self, url, json=None, timeout=None):
+ self.posts.append(json)
+ if self.reject_first and len(self.posts) == 1:
+ return CreateCollectionApiShapeTests.FakeResponse(400, {
+ "error": {"details": [{"errorMessages": [
+ "Unknown operation 'name' available ops are "
+ "'[set-alias-property, delete-alias, create,
create-alias]'"]}]}})
+ return CreateCollectionApiShapeTests.FakeResponse(200,
{"responseHeader": {"status": 0}})
+
+ def client_with(self, session):
+ admin = client.SolrAdminClient("localhost")
+ admin._session = session
+ return admin
+
+ def test_a_modern_solr_is_asked_once(self):
+ session = self.FakeSession(reject_first=False)
+ self.client_with(session).create_collection("c", configset="c")
+ self.assertEqual(1, len(session.posts))
+ self.assertEqual("c", session.posts[0]["name"])
+
+ def test_an_older_solr_is_retried_with_the_command_envelope(self):
+ session = self.FakeSession(reject_first=True)
+ self.client_with(session).create_collection("c", configset="c",
num_shards=2)
+ self.assertEqual(2, len(session.posts))
+ self.assertNotIn("create", session.posts[0])
+ self.assertEqual(2, session.posts[1]["create"]["numShards"])
+
+ def test_an_unrelated_400_is_not_retried(self):
+ # Only "unknown operation" means Solr wanted a different shape; every
+ # other 400 is a real failure and must surface as one.
+ session = self.FakeSession(reject_first=False)
+
+ def post(url, json=None, timeout=None):
+ session.posts.append(json)
+ return self.FakeResponse(400, {"error": {"msg": "Collection 'c'
already exists"}})
+
+ session.post = post
+ with self.assertRaises(client.CollectionAlreadyExistsError):
+ self.client_with(session).create_collection("c", configset="c")
+ self.assertEqual(1, len(session.posts))