From f8d3db44ad3282aa1329a9c9516590ef7d8de4cc Mon Sep 17 00:00:00 2001 From: Duda Nogueira Date: Fri, 28 Aug 2026 18:14:32 -0300 Subject: [PATCH 1/3] fix(tenants): split tenant updates at the server's limit of 100 PUT /schema/{class}/tenants rejects more than 100 tenants with HTTP 422, so updating more than that failed outright -- and with it activate, deactivate and offload, which all delegate to update. The Python and TypeScript clients split the request internally, so the same code written against either of them worked and the Java one did not. update() now sends the tenants in batches of 100, on the sync and async clients alike; the async one chains them so they reach the server one after another rather than all at once. Adding tenants stays a single request: the server validates it with allowOverHundred=true, so only updates are capped. More than one request means a partial update is now possible: if a batch fails, the tenants of the preceding batches stay updated and the error propagates. Python and TypeScript behave the same way; it is documented on both update() methods. MockRestTransport.performRequestAsync returned null, which no chained caller could compose on; it returns a completed future now. Closes #615 Claude-Session: https://claude.ai/code/session_01WmY5dAGWCccWDoqkKNC2JU --- .../io/weaviate/integration/TenantsITest.java | 34 +++++ .../tenants/UpdateTenantsRequest.java | 30 +++++ .../tenants/WeaviateTenantsClient.java | 11 +- .../tenants/WeaviateTenantsClientAsync.java | 19 ++- .../tenants/UpdateTenantsBatchingTest.java | 121 ++++++++++++++++++ .../testutil/transport/MockRestTransport.java | 4 +- 6 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 src/test/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsBatchingTest.java diff --git a/src/it/java/io/weaviate/integration/TenantsITest.java b/src/it/java/io/weaviate/integration/TenantsITest.java index c765ae080..e2b44e648 100644 --- a/src/it/java/io/weaviate/integration/TenantsITest.java +++ b/src/it/java/io/weaviate/integration/TenantsITest.java @@ -1,5 +1,8 @@ package io.weaviate.integration; +import java.util.List; +import java.util.stream.IntStream; + import org.assertj.core.api.Assertions; import org.junit.Test; @@ -71,4 +74,35 @@ public void test_tenantLifecycle() throws Exception { Assertions.assertThat(things.tenants.exists(owen.name())) .describedAs("%s not exists", owen.name()).isFalse(); } + + /** + * The server refuses to update more than 100 tenants at once, so the client + * splits longer lists -- otherwise activate/deactivate of 101+ tenants fails + * with HTTP 422 ("maximum number of tenants allowed to be updated + * simultaneously is 100"). + */ + @Test + public void test_updateMoreThanOneHundredTenants() throws Exception { + // Arrange: 250 tenants, comfortably over two batch boundaries. + var nsMany = ns("ManyTenants"); + client.collections.create(nsMany, c -> c.multiTenancy(mt -> mt.autoTenantCreation(false))); + var many = client.collections.use(nsMany); + + List names = IntStream.rangeClosed(1, 250).mapToObj(i -> "tenant-" + i).toList(); + // Creating is not capped by the server and goes out in a single request. + many.tenants.create(names.stream().map(Tenant::active).toList()); + Assertions.assertThat(many.tenants.list()).as("created").hasSize(250); + + // Act + many.tenants.deactivate(names); + + // Assert + eventually(() -> many.tenants.list().stream().allMatch(Tenant::isInactive), + 200, 5, "not all tenants were deactivated"); + + // ...and back, to cover the activate path as well. + many.tenants.activate(names); + eventually(() -> many.tenants.list().stream().allMatch(Tenant::isActive), + 200, 5, "not all tenants were activated"); + } } diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsRequest.java index c17334be0..4b8bec4ce 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsRequest.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsRequest.java @@ -1,5 +1,6 @@ package io.weaviate.client6.v1.api.collections.tenants; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -9,6 +10,35 @@ import io.weaviate.client6.v1.internal.rest.SimpleEndpoint; public record UpdateTenantsRequest(List tenants) { + /** + * How many tenants the server accepts in a single update. + * + *

+ * {@code PUT /schema/{class}/tenants} rejects more than this with HTTP 422. + * Note the asymmetry: adding tenants is not capped, only updating them, so + * {@link CreateTenantsRequest} sends whatever it is given. + */ + static final int MAX_TENANTS_PER_REQUEST = 100; + + /** + * Split the tenants into requests the server will accept. + * + *

+ * The batches are views onto {@code tenants}, so the caller must not mutate it + * while they are in flight. + */ + static List> batches(List tenants) { + if (tenants.size() <= MAX_TENANTS_PER_REQUEST) { + return List.of(tenants); + } + + var batches = new ArrayList>(); + for (int from = 0; from < tenants.size(); from += MAX_TENANTS_PER_REQUEST) { + batches.add(tenants.subList(from, Math.min(from + MAX_TENANTS_PER_REQUEST, tenants.size()))); + } + return batches; + } + static Endpoint endpoint(CollectionDescriptor collection) { return SimpleEndpoint.sideEffect( __ -> "PUT", diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java index fadb596db..954d4530d 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java @@ -119,6 +119,13 @@ public void update(Tenant... tenants) throws IOException { /** * Update tenant configuration. * + *

+ * The server accepts at most + * {@value UpdateTenantsRequest#MAX_TENANTS_PER_REQUEST} tenants per update, so + * longer lists are sent as several sequential requests. That makes a partial + * update possible: if one request fails, the tenants of the preceding ones stay + * updated and the error is propagated. + * * @param tenants Tenant names. * @throws WeaviateApiException in case the server returned with an * error status code. @@ -127,7 +134,9 @@ public void update(Tenant... tenants) throws IOException { * or the server being unavailable. */ public void update(List tenants) throws IOException { - this.restTransport.performRequest(new UpdateTenantsRequest(tenants), UpdateTenantsRequest.endpoint(collection)); + for (var batch : UpdateTenantsRequest.batches(tenants)) { + this.restTransport.performRequest(new UpdateTenantsRequest(batch), UpdateTenantsRequest.endpoint(collection)); + } } /** diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClientAsync.java b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClientAsync.java index 5725f55ac..222aecfc3 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClientAsync.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClientAsync.java @@ -54,9 +54,24 @@ public CompletableFuture update(Tenant... tenants) throws IOException { return update(Arrays.asList(tenants)); } + /** + * Update tenant configuration. + * + *

+ * The server accepts at most + * {@value UpdateTenantsRequest#MAX_TENANTS_PER_REQUEST} tenants per update, so + * longer lists are sent as several requests, chained so that they reach the + * server one after another. That makes a partial update possible: if one + * request fails, the tenants of the preceding ones stay updated and the + * returned future completes exceptionally. + */ public CompletableFuture update(List tenants) throws IOException { - return this.restTransport.performRequestAsync(new UpdateTenantsRequest(tenants), - UpdateTenantsRequest.endpoint(collection)); + CompletableFuture updated = CompletableFuture.completedFuture(null); + for (var batch : UpdateTenantsRequest.batches(tenants)) { + updated = updated.thenCompose(__ -> this.restTransport.performRequestAsync( + new UpdateTenantsRequest(batch), UpdateTenantsRequest.endpoint(collection))); + } + return updated; } public CompletableFuture delete(String... tenants) throws IOException { diff --git a/src/test/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsBatchingTest.java b/src/test/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsBatchingTest.java new file mode 100644 index 000000000..68c9310d7 --- /dev/null +++ b/src/test/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsBatchingTest.java @@ -0,0 +1,121 @@ +package io.weaviate.client6.v1.api.collections.tenants; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; + +import org.assertj.core.api.Assertions; +import org.junit.Test; + +import io.weaviate.client6.v1.internal.orm.CollectionDescriptor; +import io.weaviate.testutil.transport.MockRestTransport; + +/** + * The server rejects an update of more than 100 tenants with HTTP 422, so the + * client splits longer lists. Adding tenants is not capped and has to keep going + * out as a single request. + */ +public class UpdateTenantsBatchingTest { + private static final CollectionDescriptor COLLECTION = CollectionDescriptor.ofMap("Things"); + + private static List tenants(int n) { + return IntStream.rangeClosed(1, n).mapToObj(i -> Tenant.active("tenant-" + i)).toList(); + } + + private static List names(int n) { + return IntStream.rangeClosed(1, n).mapToObj(i -> "tenant-" + i).toList(); + } + + /** Collect the body of every request the client sent, in order. */ + private static List bodies(MockRestTransport transport, int expectAtMost) { + var seen = new ArrayList(); + var assertions = new MockRestTransport.AssertFunction[expectAtMost]; + for (var i = 0; i < expectAtMost; i++) { + assertions[i] = (method, url, body, query) -> seen.add(body); + } + transport.assertNext(assertions); + return seen; + } + + @Test + public void test_batchesOfAtMost100() { + Assertions.assertThat(UpdateTenantsRequest.batches(tenants(100))) + .as("exactly at the limit is one request").hasSize(1); + Assertions.assertThat(UpdateTenantsRequest.batches(tenants(101))) + .as("one over the limit splits") + .extracting(List::size).containsExactly(100, 1); + Assertions.assertThat(UpdateTenantsRequest.batches(tenants(250))) + .extracting(List::size).containsExactly(100, 100, 50); + } + + @Test + public void test_batchesCoverEveryTenantInOrder() { + var all = tenants(250); + + var flattened = new ArrayList(); + UpdateTenantsRequest.batches(all).forEach(flattened::addAll); + + Assertions.assertThat(flattened).isEqualTo(all); + } + + @Test + public void test_emptyListIsASingleEmptyBatch() { + Assertions.assertThat(UpdateTenantsRequest.batches(List.of())).containsExactly(List.of()); + } + + @Test + public void test_updateSplitsIntoSeveralRequests() throws IOException { + var transport = new MockRestTransport(); + + new WeaviateTenantsClient(COLLECTION, transport, null).update(tenants(101)); + + var bodies = bodies(transport, 3); + Assertions.assertThat(bodies).as("101 tenants -> 2 requests").hasSize(2); + Assertions.assertThat(bodies.get(0)).contains("tenant-100").doesNotContain("tenant-101"); + Assertions.assertThat(bodies.get(1)).contains("tenant-101").doesNotContain("tenant-100"); + } + + @Test + public void test_updateAtTheLimitIsASingleRequest() throws IOException { + var transport = new MockRestTransport(); + + new WeaviateTenantsClient(COLLECTION, transport, null).update(tenants(100)); + + Assertions.assertThat(bodies(transport, 2)).hasSize(1); + } + + /** activate/deactivate/offload all delegate to update, so they split too. */ + @Test + public void test_deactivateSplitsIntoSeveralRequests() throws IOException { + var transport = new MockRestTransport(); + + new WeaviateTenantsClient(COLLECTION, transport, null).deactivate(names(201)); + + var bodies = bodies(transport, 4); + Assertions.assertThat(bodies).as("201 tenants -> 3 requests").hasSize(3); + Assertions.assertThat(bodies).allSatisfy(body -> Assertions.assertThat(body).contains("INACTIVE")); + } + + @Test + public void test_createIsNotSplit() throws IOException { + var transport = new MockRestTransport(); + + new WeaviateTenantsClient(COLLECTION, transport, null).create(tenants(250)); + + var bodies = bodies(transport, 2); + Assertions.assertThat(bodies).as("adding tenants is not capped by the server").hasSize(1); + Assertions.assertThat(bodies.get(0)).contains("tenant-1").contains("tenant-250"); + } + + @Test + public void test_asyncUpdateSplitsIntoSeveralRequests() throws Exception { + var transport = new MockRestTransport(); + + new WeaviateTenantsClientAsync(COLLECTION, transport, null).update(tenants(101)).get(); + + var bodies = bodies(transport, 3); + Assertions.assertThat(bodies).hasSize(2); + Assertions.assertThat(bodies.get(1)).contains("tenant-101"); + } +} diff --git a/src/test/java/io/weaviate/testutil/transport/MockRestTransport.java b/src/test/java/io/weaviate/testutil/transport/MockRestTransport.java index 7991078cd..1698eaeef 100644 --- a/src/test/java/io/weaviate/testutil/transport/MockRestTransport.java +++ b/src/test/java/io/weaviate/testutil/transport/MockRestTransport.java @@ -55,7 +55,9 @@ public ResponseT performRequest(RequestT reque public CompletableFuture performRequestAsync(RequestT request, Endpoint endpoint) { requests.add(new Request<>(request, endpoint)); - return null; + // A completed future rather than null, so callers which chain requests + // (thenCompose) can be tested against this transport. + return CompletableFuture.completedFuture(null); } @Override From 7933bde70e9d2a3f36bd090f437b857db3cab4d5 Mon Sep 17 00:00:00 2001 From: Duda Nogueira Date: Mon, 9 Nov 2026 18:15:49 -0300 Subject: [PATCH 2/3] refactor(tenants): drop explanatory comments, copy update batches - Remove the comments and Javadoc added in the previous commit. - Copy each batch so later changes to the caller's list cannot reach requests that are still in flight. - Fix the @param of update(List): it takes tenant configurations. - MockRestTransport.performRequestAsync answers BooleanEndpoint with TRUE, like performRequest. Claude-Session: https://claude.ai/code/session_01VCkN66JyKNd2RQpFE46nCE --- .../io/weaviate/integration/TenantsITest.java | 11 ----------- .../tenants/UpdateTenantsRequest.java | 19 ++----------------- .../tenants/WeaviateTenantsClient.java | 9 +-------- .../tenants/WeaviateTenantsClientAsync.java | 11 ----------- .../tenants/UpdateTenantsBatchingTest.java | 7 ------- .../testutil/transport/MockRestTransport.java | 6 ++++-- 6 files changed, 7 insertions(+), 56 deletions(-) diff --git a/src/it/java/io/weaviate/integration/TenantsITest.java b/src/it/java/io/weaviate/integration/TenantsITest.java index e2b44e648..8c9e9bc13 100644 --- a/src/it/java/io/weaviate/integration/TenantsITest.java +++ b/src/it/java/io/weaviate/integration/TenantsITest.java @@ -75,32 +75,21 @@ public void test_tenantLifecycle() throws Exception { .describedAs("%s not exists", owen.name()).isFalse(); } - /** - * The server refuses to update more than 100 tenants at once, so the client - * splits longer lists -- otherwise activate/deactivate of 101+ tenants fails - * with HTTP 422 ("maximum number of tenants allowed to be updated - * simultaneously is 100"). - */ @Test public void test_updateMoreThanOneHundredTenants() throws Exception { - // Arrange: 250 tenants, comfortably over two batch boundaries. var nsMany = ns("ManyTenants"); client.collections.create(nsMany, c -> c.multiTenancy(mt -> mt.autoTenantCreation(false))); var many = client.collections.use(nsMany); List names = IntStream.rangeClosed(1, 250).mapToObj(i -> "tenant-" + i).toList(); - // Creating is not capped by the server and goes out in a single request. many.tenants.create(names.stream().map(Tenant::active).toList()); Assertions.assertThat(many.tenants.list()).as("created").hasSize(250); - // Act many.tenants.deactivate(names); - // Assert eventually(() -> many.tenants.list().stream().allMatch(Tenant::isInactive), 200, 5, "not all tenants were deactivated"); - // ...and back, to cover the activate path as well. many.tenants.activate(names); eventually(() -> many.tenants.list().stream().allMatch(Tenant::isActive), 200, 5, "not all tenants were activated"); diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsRequest.java index 4b8bec4ce..6771929b2 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsRequest.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsRequest.java @@ -10,31 +10,16 @@ import io.weaviate.client6.v1.internal.rest.SimpleEndpoint; public record UpdateTenantsRequest(List tenants) { - /** - * How many tenants the server accepts in a single update. - * - *

- * {@code PUT /schema/{class}/tenants} rejects more than this with HTTP 422. - * Note the asymmetry: adding tenants is not capped, only updating them, so - * {@link CreateTenantsRequest} sends whatever it is given. - */ static final int MAX_TENANTS_PER_REQUEST = 100; - /** - * Split the tenants into requests the server will accept. - * - *

- * The batches are views onto {@code tenants}, so the caller must not mutate it - * while they are in flight. - */ static List> batches(List tenants) { if (tenants.size() <= MAX_TENANTS_PER_REQUEST) { - return List.of(tenants); + return List.of(List.copyOf(tenants)); } var batches = new ArrayList>(); for (int from = 0; from < tenants.size(); from += MAX_TENANTS_PER_REQUEST) { - batches.add(tenants.subList(from, Math.min(from + MAX_TENANTS_PER_REQUEST, tenants.size()))); + batches.add(List.copyOf(tenants.subList(from, Math.min(from + MAX_TENANTS_PER_REQUEST, tenants.size())))); } return batches; } diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java index 954d4530d..92e632814 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java @@ -119,14 +119,7 @@ public void update(Tenant... tenants) throws IOException { /** * Update tenant configuration. * - *

- * The server accepts at most - * {@value UpdateTenantsRequest#MAX_TENANTS_PER_REQUEST} tenants per update, so - * longer lists are sent as several sequential requests. That makes a partial - * update possible: if one request fails, the tenants of the preceding ones stay - * updated and the error is propagated. - * - * @param tenants Tenant names. + * @param tenants Tenant configurations. * @throws WeaviateApiException in case the server returned with an * error status code. * @throws IOException in case the request was not sent successfully diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClientAsync.java b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClientAsync.java index 222aecfc3..c895c4731 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClientAsync.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClientAsync.java @@ -54,17 +54,6 @@ public CompletableFuture update(Tenant... tenants) throws IOException { return update(Arrays.asList(tenants)); } - /** - * Update tenant configuration. - * - *

- * The server accepts at most - * {@value UpdateTenantsRequest#MAX_TENANTS_PER_REQUEST} tenants per update, so - * longer lists are sent as several requests, chained so that they reach the - * server one after another. That makes a partial update possible: if one - * request fails, the tenants of the preceding ones stay updated and the - * returned future completes exceptionally. - */ public CompletableFuture update(List tenants) throws IOException { CompletableFuture updated = CompletableFuture.completedFuture(null); for (var batch : UpdateTenantsRequest.batches(tenants)) { diff --git a/src/test/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsBatchingTest.java b/src/test/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsBatchingTest.java index 68c9310d7..713a3560a 100644 --- a/src/test/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsBatchingTest.java +++ b/src/test/java/io/weaviate/client6/v1/api/collections/tenants/UpdateTenantsBatchingTest.java @@ -11,11 +11,6 @@ import io.weaviate.client6.v1.internal.orm.CollectionDescriptor; import io.weaviate.testutil.transport.MockRestTransport; -/** - * The server rejects an update of more than 100 tenants with HTTP 422, so the - * client splits longer lists. Adding tenants is not capped and has to keep going - * out as a single request. - */ public class UpdateTenantsBatchingTest { private static final CollectionDescriptor COLLECTION = CollectionDescriptor.ofMap("Things"); @@ -27,7 +22,6 @@ private static List names(int n) { return IntStream.rangeClosed(1, n).mapToObj(i -> "tenant-" + i).toList(); } - /** Collect the body of every request the client sent, in order. */ private static List bodies(MockRestTransport transport, int expectAtMost) { var seen = new ArrayList(); var assertions = new MockRestTransport.AssertFunction[expectAtMost]; @@ -85,7 +79,6 @@ public void test_updateAtTheLimitIsASingleRequest() throws IOException { Assertions.assertThat(bodies(transport, 2)).hasSize(1); } - /** activate/deactivate/offload all delegate to update, so they split too. */ @Test public void test_deactivateSplitsIntoSeveralRequests() throws IOException { var transport = new MockRestTransport(); diff --git a/src/test/java/io/weaviate/testutil/transport/MockRestTransport.java b/src/test/java/io/weaviate/testutil/transport/MockRestTransport.java index 1698eaeef..19225964a 100644 --- a/src/test/java/io/weaviate/testutil/transport/MockRestTransport.java +++ b/src/test/java/io/weaviate/testutil/transport/MockRestTransport.java @@ -51,12 +51,14 @@ public ResponseT performRequest(RequestT reque return null; } + @SuppressWarnings("unchecked") @Override public CompletableFuture performRequestAsync(RequestT request, Endpoint endpoint) { requests.add(new Request<>(request, endpoint)); - // A completed future rather than null, so callers which chain requests - // (thenCompose) can be tested against this transport. + if (endpoint instanceof BooleanEndpoint) { + return CompletableFuture.completedFuture((ResponseT) Boolean.TRUE); + } return CompletableFuture.completedFuture(null); } From 31414c3b206ed9f678274a87c9dcc0588f54903e Mon Sep 17 00:00:00 2001 From: Duda Nogueira Date: Tue, 10 Nov 2026 12:34:50 -0300 Subject: [PATCH 3/3] docs(tenants): describe update(Tenant...) parameters as tenant configurations --- .../v1/api/collections/tenants/WeaviateTenantsClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java index 92e632814..94d16e305 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/tenants/WeaviateTenantsClient.java @@ -105,7 +105,7 @@ public List get(List tenants) { /** * Update tenant configuration. * - * @param tenants Tenant names. + * @param tenants Tenant configurations. * @throws WeaviateApiException in case the server returned with an * error status code. * @throws IOException in case the request was not sent successfully