From 6c785de6c14d0ac396f01ee61d6e46ef93788387 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:30:26 +0100 Subject: [PATCH 01/22] test(replication): add Solr version to lifecycle matrix --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6dcca1..73640b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,7 @@ jobs: env: SOLR_BASE_URL: http://localhost:8983/solr SOLR_CORE: integration + SOLR_VERSION: "${{ matrix.solr-version }}" SOLR_BACKUP_LOCATION: /var/solr/backups - name: Print Solr logs if: always() From 0c360b8fee1be80985e7cedc173704fefa08f8ea Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:30:28 +0100 Subject: [PATCH 02/22] test(replication): update docs/api-coverage.md --- docs/api-coverage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 31ff5fb..e9c8a1a 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -26,7 +26,7 @@ A generic request method does not mean that every endpoint has a typed wrapper, | Security | `security(area, command)` | Security-enabled standalone matrix covers anonymous 401 responses for both authentication and authorization, authenticated reads, authentication property mutation/read-back, and malformed JSON rejection on Solr 8.11.4/9.11.0/10.1.0. Unit tests verify Basic/Bearer credentials and Authorization headers are not exposed through client HTTP errors. See Issues #86–#88. | | System | `systemInfo()`, `systemProperties()`, `systemThreads()`, `systemLogging()` | System info, non-empty JVM/process properties, a non-empty thread dump, and logger metadata are exercised on pinned Solr 8.11.4/9.11.0/10.1.0. Optional fields remain version-dependent; response models preserve unknown fields. See Issues #89 and #91. |\n| Metrics | `metrics()` with typed group/registry/prefix/regex filters; exported `SolrMetricsJsonResponse`, `SolrMetricsRegistry`, and open `SolrMetricValue`; JSON/Prometheus response union | JVM response-format coverage remains distinct (observed JSON on Solr 8/9 and Prometheus exposition on 10.1). A `node` group integration check runs on all pinned versions, asserting a non-empty JSON registry on Solr 8/9 and Prometheus samples on 10.1; registry, prefix, and regex filters are asserted against JSON responses on Solr 8/9 (Solr 10.1 supports Prometheus/OpenMetrics, not the JSON writer). Consumer fixtures verify the extensible registry/value envelope; plugin-specific metrics remain open and unenumerated. See Issues #90, #91, and #236. | | Backup and restore | `replication("backup" | "restore" | "restorestatus" | "deletebackup")`, `collections("BACKUP" | "RESTORE" | "LISTBACKUP" | "DELETEBACKUP" | "REQUESTSTATUS" | "DELETESTATUS")` | Typed core and SolrCloud backup/restore parameters and response models, including async status polling. Full core restore and SolrCloud collection backup/restore cycles are integration-tested on Solr 8.11.4/9.11.0/10.1.0 using local filesystem repositories and shared SolrCloud storage. See [backup and restore docs](backup-restore.md). | -| Replication | `replication(command, params)`, `replicationFile(params)`; exported command-specific parameter and response models | V1 ReplicationHandler commands for index version, details, file list, leader/follower control (Solr 10 fetchindex/abortfetch; Solr 8/9 snappull/abort), core backup/restore/status/delete, and raw file-content streaming. Integration coverage includes core backup/restore cycles on Solr 8.11.4/9.11.0/10.1.0. An end-to-end leader/follower fetch cycle is not yet certified. See Issue #95 and [replication docs](replication.md). | +| Replication | `replication(command, params)`, `replicationFile(params)`; exported command-specific parameter and response models | V1 ReplicationHandler commands for index version, details, file list, leader/follower control (Solr 10 fetchindex/abortfetch; Solr 8/9 snappull/abort), core backup/restore/status/delete, and raw file-content streaming. Integration coverage includes core backup/restore cycles and a disposable two-core leader/follower fetch/read-back cycle on Solr 8.11.4/9.11.0/10.1.0. The test uses `snappull` on Solr 8/9 and `fetchindex` on Solr 10; abort and polling lifecycle behavior remains unit-only. See Issue #95 and [replication docs](replication.md). | | Streaming Expressions | `stream()` | Sends `expr` with `wt=json` and parses the complete JSON response through the shared transport. It is not an incremental tuple-stream parser: no chunk-boundary protocol or memory-bound/backpressure guarantee is claimed. SolrCloud search is integration-tested on pinned Solr 8.11.4/9.11.0/10.1.0; unit tests cover JSON parsing, caller cancellation during body consumption, and cleanup after body-stream errors. See [Streaming Expressions](streaming.md) and Issue #93. | | Export | `export()`, `exportStream()`; exported `SolrExportParams`, `SolrExportSort`, and `SolrExportFieldType` | Export requests require typed `fl` and `sort` parameters, support repeated filter queries, structured or raw sort expressions, batch sizing, JSON/JavaBin writers, and an explicit custom-parameter escape hatch. The raw Response preserves backpressure-aware streaming; `exportStream()` exposes its byte body directly. Sort fields must be single-valued docValues fields (int, long, float, double, or string); field lists additionally support date and boolean and may be multi-valued. Solr 10.1 `includeStoredFields` support is exposed but requires version-specific verification. Existing raw Export response tests pass on pinned Solr 8.11.4/9.11.0/10.1.0; typed parameter serialization and streamed chunk consumption have unit coverage. See [Export docs](export.md) and Issues #92–#94. | | Advanced / Raw | `requestRaw(path, options)` with typed method/query/header/body/control options and a raw Fetch `Response` | Relative paths and same-origin absolute URLs use the shared transport. Cross-origin URLs, embedded credentials, and URL fragments are rejected before Fetch, preventing configured credentials from being sent to unrelated hosts. Generic request()/requestRaw() transport behavior has unit coverage only; arbitrary custom endpoint behavior remains unverified until endpoint-specific integration tests exist. See [raw requests](raw-requests.md) and Issue #98. | From da5026772c00cccd22708ec364989895a9a91217 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:30:31 +0100 Subject: [PATCH 03/22] test(replication): update docs/api-inventory.json --- docs/api-inventory.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/api-inventory.json b/docs/api-inventory.json index 6ef9b53..a321008 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -396,11 +396,12 @@ "test/replication.test.ts", "test/replication-api-types.ts", "test/integration/solr-replication.test.ts", + "test/integration/solr-replication-lifecycle.test.ts", "test/backup-restore.test.ts", "test/backup-restore-api-types.ts", "test/integration/solr-core-backup-restore.test.ts" ], - "notes": "Typed V1 ReplicationHandler commands for enablereplication, disablereplication, indexversion, fetchindex, abortfetch, snappull, abort, enablepoll, disablepoll, details, filelist, backup, restore, restorestatus, and deletebackup; replicationFile() returns raw filecontent streams. Live integration coverage on Solr 8.11.4, 9.11.0, and 10.1.0 covers indexversion, details, filelist, and filecontent. enablereplication/disablereplication are integration-tested on a configured leader; fetchindex/abortfetch (Solr 10), snappull/abort (Solr 8/9), enablepoll, and disablepoll have unit serialization coverage, but a multi-core leader/follower fetch cycle is not yet certified. Core-level backup, restore, restorestatus, and deletebackup are integration-tested on all three pinned versions. Filelist uses the generation returned by newer Solr indexversion responses and falls back to the legacy indexversion parameter when that field is absent. Additional operation evidence: Core-level ReplicationHandler backup, restore, restorestatus, and deletebackup are typed. Backup defaults to the core dataDir; restore status is polled with restorestatus. Full backup/restore/read-back/delete cycle is integration-tested on Solr 8.11.4, 9.11.0, and 10.1.0. Custom filesystem locations must be permitted by server allowPaths." + "notes": "Typed V1 ReplicationHandler commands for enablereplication, disablereplication, indexversion, fetchindex, abortfetch, snappull, abort, enablepoll, disablepoll, details, filelist, backup, restore, restorestatus, and deletebackup; replicationFile() returns raw filecontent streams. Live integration coverage on Solr 8.11.4, 9.11.0, and 10.1.0 covers indexversion, details, filelist, filecontent, leader enable/disable, and a disposable two-core leader/follower fetch with follower query read-back. The integration job supplies SOLR_VERSION so the test uses fetchindex on Solr 10 and snappull on Solr 8/9. abortfetch/abort, enablepoll, and disablepoll retain unit serialization coverage only; an in-progress abort lifecycle is not certified. Core-level backup, restore, restorestatus, and deletebackup are integration-tested on all three pinned versions. Filelist uses the generation returned by newer Solr indexversion responses and falls back to the legacy indexversion parameter when that field is absent. Additional operation evidence: Core-level ReplicationHandler backup, restore, restorestatus, and deletebackup are typed. Backup defaults to the core dataDir; restore status is polled with restorestatus. Full backup/restore/read-back/delete cycle is integration-tested on Solr 8.11.4, 9.11.0, and 10.1.0. Custom filesystem locations must be permitted by server allowPaths." }, { "id": "replication.file", From 770a1ec27ba1737784620270d25750b66a2a0677 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:30:34 +0100 Subject: [PATCH 04/22] test(replication): update docs/compatibility.md --- docs/compatibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index f255131..f12d501 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -38,7 +38,7 @@ The 8.11, 9.11, and 10.1 guides describe the API families below. The Replica Man | `security()` — authentication and authorization handlers | Documented | Documented | Documented | Tested: authenticated BasicAuth/RuleBasedAuthorization reads, anonymous 401 on both handlers, authentication `set-property` read-back, and malformed JSON rejection on pinned 8.11.4 / 9.11.0 / 10.1.0 images. Unit tests verify Basic/Bearer credentials are not exposed by client HTTP errors | | `stream()` — `/stream` | Documented | Documented | Documented | Tested: Streaming Expressions search against a disposable SolrCloud collection on pinned 8.11.4 / 9.11.0 / 10.1.0 images (CI #145) | | `export()` — `/export` | Documented | Documented | Documented | Tested: raw response with docValues sort field on pinned 8.11.4 / 9.11.0 / 10.1.0 images | -| `replication()` / `replicationFile()` — `/solr/{core}/replication` | Documented | Documented | Documented | Tested: indexversion, details, filelist (generation on newer versions / legacy indexversion on older versions), streamed filecontent, enablereplication/disablereplication, core backup, restorestatus, restore, and deletebackup on pinned 8.11.4 / 9.11.0 / 10.1.0 images. fetchindex/abortfetch (Solr 10), snappull/abort (Solr 8/9), enablepoll, and disablepoll have unit serialization coverage; an end-to-end multi-core leader/follower fetch cycle is not yet certified | +| `replication()` / `replicationFile()` — `/solr/{core}/replication` | Documented | Documented | Documented | Tested: indexversion, details, filelist (generation on newer versions / legacy indexversion on older versions), streamed filecontent, enablereplication/disablereplication, core backup, restorestatus, restore, deletebackup, and a disposable two-core leader/follower fetch with follower query read-back on pinned 8.11.4 / 9.11.0 / 10.1.0 images. The fetch uses snappull on Solr 8/9 and fetchindex on Solr 10. Abort and polling lifecycle behavior remains unit-only | | Core and collection backup/restore | `replication("backup" | "restore" | "restorestatus" | "deletebackup")`, `collections("BACKUP" | "RESTORE" | "LISTBACKUP" | "DELETEBACKUP" | "REQUESTSTATUS" | "DELETESTATUS")` | Documented | Documented | Documented | Tested: full core backup/restore/read-back and SolrCloud collection backup/list/restore/read-back/delete with asynchronous status polling on pinned 8.11.4 / 9.11.0 / 10.1.0 images using local filesystem repositories and shared SolrCloud storage | | `request()` / `requestRaw()` — generic caller-defined transport | Depends on the selected endpoint | Depends on the selected endpoint | Depends on the selected endpoint | Generic transport behavior has unit tests only; no endpoint-independent live Solr integration claim is made. Each custom endpoint remains unverified until tested individually | From fba03294e9568eba1b8f3f4bf397a5f1d7874ae0 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:31:00 +0100 Subject: [PATCH 05/22] docs(replication): record lifecycle verification boundary --- docs/replication.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/replication.md b/docs/replication.md index 375730c..8ebb459 100644 --- a/docs/replication.md +++ b/docs/replication.md @@ -103,4 +103,7 @@ Supported directory types are `file` (Lucene index files), `cf` (configuration f - The client currently exposes the V1 handler path. Solr 10.1 also documents V2 equivalents for selected file and index-version operations; no dedicated V2 wrapper is provided. - The handler may not be configured for user-managed leader/follower replication on every core. The server must have the appropriate ReplicationHandler configuration before control commands can be used. +- The pinned integration matrix creates disposable leader and follower cores, commits a unique leader document, triggers `snappull` on Solr 8/9 or `fetchindex` on Solr 10, and verifies that the document becomes searchable on the follower. This verifies a complete fetch/read-back cycle, not polling or abort of an in-progress transfer. +- `abort` (Solr 8/9) and `abortfetch` (Solr 10), plus `enablepoll`/`disablepoll`, have request-serialization unit coverage; cancellation of a live transfer remains unverified. + See the [Solr 8.11](https://solr.apache.org/__root/docs.solr.apache.org/guide/8_11/deployment-guide/user-managed-index-replication.html), [Solr 9.11](https://solr.apache.org/guide/solr/9_11/deployment-guide/user-managed-index-replication.html), and [Solr 10.1](https://solr.apache.org/guide/solr/10_1/deployment-guide/user-managed-index-replication.html) guides for server configuration and command semantics. From e3934e6b75b156118d51a60cdbc74945ce04f6f8 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:31:02 +0100 Subject: [PATCH 06/22] test(replication): register lifecycle integration test --- test/integration/all.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/integration/all.test.ts b/test/integration/all.test.ts index 36f2f2f..96775af 100644 --- a/test/integration/all.test.ts +++ b/test/integration/all.test.ts @@ -15,6 +15,7 @@ import "./solr-cursor.test.js"; import "./solr-schema.test.js"; import "./solr-security.test.js"; import "./solr-replication.test.js"; +import "./solr-replication-lifecycle.test.js"; import "./solr-core-backup-restore.test.js"; import "./solrcloud-backup-restore.test.js"; import "./solr-smoke.test.js"; From 154ba70030cf60daebbf66bc8d580af7cea326a6 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:31:04 +0100 Subject: [PATCH 07/22] test(replication): verify leader follower fetch lifecycle --- .../solr-replication-lifecycle.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 test/integration/solr-replication-lifecycle.test.ts diff --git a/test/integration/solr-replication-lifecycle.test.ts b/test/integration/solr-replication-lifecycle.test.ts new file mode 100644 index 0000000..b07f115 --- /dev/null +++ b/test/integration/solr-replication-lifecycle.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { SolrClient } from "../../src/index.js"; +import { integrationTest } from "./fixtures.js"; + +integrationTest("ReplicationHandler fetches a committed leader index into an isolated follower core", async ({ + createCore, + createId +}) => { + const leaderCore = await createCore("replication_lifecycle_leader"); + const followerCore = await createCore("replication_lifecycle_follower"); + const baseUrl = process.env.SOLR_BASE_URL!; + const username = process.env.SOLR_USERNAME; + const password = process.env.SOLR_PASSWORD; + const makeClient = (core: string) => new SolrClient({ + baseUrl, + core, + timeoutMs: 30_000, + ...(username && password ? { auth: { type: "basic" as const, username, password } } : {}) + }); + const leader = makeClient(leaderCore); + const follower = makeClient(followerCore); + const leaderUrl = `${baseUrl.replace(/\/$/, "")}/${leaderCore}/replication`; + const documentId = createId("replication_lifecycle"); + + await leader.config({ + "update-requesthandler": { + name: "/replication", + class: "solr.ReplicationHandler", + customOptions: { leader: { replicateAfter: "commit" } } + } + }); + await follower.config({ + "update-requesthandler": { + name: "/replication", + class: "solr.ReplicationHandler", + customOptions: { follower: { leaderUrl } } + } + }); + + await leader.add([{ id: documentId, title: "leader follower lifecycle" }]); + await leader.commit({ waitSearcher: true }); + + const leaderVersion = await leader.replication("indexversion"); + assert.equal(leaderVersion.responseHeader?.status, 0); + const leaderGeneration = leaderVersion.generation ?? Number(leaderVersion.indexversion); + assert.ok(Number.isFinite(leaderGeneration) && leaderGeneration > 0, "leader must publish a replicable index"); + + const solrMajor = process.env.SOLR_VERSION?.split(".")[0]; + const fetchCommand = solrMajor === "10" ? "fetchindex" : "snappull"; + const fetchResponse = await follower.replication(fetchCommand, { leaderUrl }); + assert.equal(fetchResponse.responseHeader?.status, 0, `${fetchCommand} should complete successfully`); + + // Replication copies Lucene index files asynchronously; assert the resulting + // searchable state rather than treating the command's HTTP response as proof. + let found = false; + for (let attempt = 0; attempt < 60; attempt += 1) { + const result = await follower.query<{ response?: { numFound?: number } }>({ query: `id:"${documentId}"`, fields: ["id"], limit: 1 }); + if ((result.response?.numFound ?? 0) === 1) { + found = true; + break; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + assert.ok(found, "follower should expose the leader document after the fetch completes"); + + const followerVersion = await follower.replication("indexversion"); + assert.equal(followerVersion.responseHeader?.status, 0); + const followerGeneration = followerVersion.generation ?? Number(followerVersion.indexversion); + assert.ok(Number.isFinite(followerGeneration) && followerGeneration > 0, "follower should publish its fetched index"); +}); From 0350e6c0cc35636e575372387cc3eaf8f515dfde Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:33:17 +0100 Subject: [PATCH 08/22] test(replication): allow loopback leader URLs in isolated CI Solr --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73640b8..54a2d33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: --volume "$RUNNER_TEMP/solr-backups:/var/solr/backups" \ --publish 8983:8983 \ --env SOLR_JAVA_MEM="-Xms512m -Xmx512m" \ - --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups" \ + --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true" \ "solr:${{ matrix.solr-version }}" \ solr-precreate integration - name: Wait for Solr and integration core @@ -125,7 +125,7 @@ jobs: --volume "$RUNNER_TEMP/solr-backups:/var/solr/backups" \ --publish 8983:8983 \ --env SOLR_JAVA_MEM="-Xms512m -Xmx512m" \ - --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups" \ + --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true" \ --entrypoint /bin/bash \ "solr:${{ matrix.solr-version }}" \ -lc 'mkdir -p /var/solr/data && cp /tmp/security.json /var/solr/data/security.json && chmod 666 /var/solr/data/security.json && exec docker-entrypoint.sh solr-precreate integration' From 2e763c2628960c480fbbd00fc9ee210b4bbc7e5f Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:36:04 +0100 Subject: [PATCH 09/22] test(replication): configure BasicAuth for secure leader fetches --- test/integration/solr-replication-lifecycle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/solr-replication-lifecycle.test.ts b/test/integration/solr-replication-lifecycle.test.ts index b07f115..fee2bf0 100644 --- a/test/integration/solr-replication-lifecycle.test.ts +++ b/test/integration/solr-replication-lifecycle.test.ts @@ -33,7 +33,7 @@ integrationTest("ReplicationHandler fetches a committed leader index into an iso "update-requesthandler": { name: "/replication", class: "solr.ReplicationHandler", - customOptions: { follower: { leaderUrl } } + customOptions: { follower: { leaderUrl, ...(username && password ? { httpBasicAuthUser: username, httpBasicAuthPassword: password } : {}) } } } }); From 157021890d03c849b452c57ec9a8940623578196 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:37:42 +0100 Subject: [PATCH 10/22] test(replication): pass Solr version to secure integration matrix --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54a2d33..a3b54c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,7 @@ jobs: env: SOLR_BASE_URL: http://localhost:8983/solr SOLR_CORE: integration + SOLR_VERSION: "${{ matrix.solr-version }}" SOLR_SECURITY: "true" SOLR_USERNAME: integration-admin SOLR_PASSWORD: integration-password From e062fea1fa998e739090dd117012d22379e510d9 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:38:32 +0100 Subject: [PATCH 11/22] test(replication): allow loopback leader URLs on Solr 10 CI --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3b54c4..b9ab3bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: --volume "$RUNNER_TEMP/solr-backups:/var/solr/backups" \ --publish 8983:8983 \ --env SOLR_JAVA_MEM="-Xms512m -Xmx512m" \ - --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true" \ + --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true -Dsolr.security.allow.urls.enabled=false" \ "solr:${{ matrix.solr-version }}" \ solr-precreate integration - name: Wait for Solr and integration core @@ -125,7 +125,7 @@ jobs: --volume "$RUNNER_TEMP/solr-backups:/var/solr/backups" \ --publish 8983:8983 \ --env SOLR_JAVA_MEM="-Xms512m -Xmx512m" \ - --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true" \ + --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true -Dsolr.security.allow.urls.enabled=false" \ --entrypoint /bin/bash \ "solr:${{ matrix.solr-version }}" \ -lc 'mkdir -p /var/solr/data && cp /tmp/security.json /var/solr/data/security.json && chmod 666 /var/solr/data/security.json && exec docker-entrypoint.sh solr-precreate integration' From edddceb0e5ab9a756b2691170116191c96b9b362 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:39:38 +0100 Subject: [PATCH 12/22] test(solrcloud): add opt-in SPLITSHARD and MIGRATE verification --- .github/workflows/ci.yml | 11 ++ docs/administration.md | 15 +++ docs/api-inventory.json | 3 +- docs/collections-api-coverage.md | 7 +- docs/endpoint-inventory.md | 2 +- test/integration/all.test.ts | 1 + test/integration/fixtures.ts | 6 +- .../solrcloud-split-migrate-optin.test.ts | 103 ++++++++++++++++++ 8 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 test/integration/solrcloud-split-migrate-optin.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9ab3bb..e685bc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,15 @@ on: branches: [master] pull_request: branches: [master] + workflow_dispatch: + inputs: + enable_destructive_solrcloud_tests: + description: "Run opt-in SPLITSHARD and MIGRATE checks on the disposable two-node cluster" + required: false + default: false + type: boolean +jobs: + test: jobs: test: strategy: @@ -160,6 +169,8 @@ jobs: run: docker logs solr-secure || true cloud-integration: + env: + SOLR_DESTRUCTIVE_OPT_IN: "${{ github.event_name == 'workflow_dispatch' && inputs.enable_destructive_solrcloud_tests == true && 'true' || 'false' }}" strategy: fail-fast: false matrix: diff --git a/docs/administration.md b/docs/administration.md index 4647f56..2156c2e 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -161,3 +161,18 @@ The alias and cluster-status integration checks exercise alias read-back, collec The V1 parameter spellings are intentional: this client calls `/solr/admin/collections`, not the Solr 10 V2 `/api/collections` resources. The V2 API maps `split.key` to `splitKey` and `target.collection` to `target`; those V2 names must not be sent through this V1 client. Reference guides: [Solr 8.1 Collections API](https://archive.apache.org/dist/lucene/solr/ref-guide/apache-solr-ref-guide-8.1.pdf), [Solr 9.2 Collection Management](https://solr.apache.org/guide/solr/9_2/deployment-guide/collection-management.html), [Solr 9.10 Shard Management](https://solr.apache.org/guide/solr/9_10/deployment-guide/shard-management.html), and [Solr 10.0 Collections API](https://solr.apache.org/guide/solr/10_0/configuration-guide/collections-api.html). + +## Opt-in SPLITSHARD and MIGRATE verification + +These operations are intentionally excluded from normal push/pull-request CI. The opt-in suite uses only collections created by the fixture with unique `integration_*` names. It refuses to run unless all of the following are true: + +- `SOLR_CLOUD=true` and `SOLR_MULTINODE=true`. +- `SOLR_DESTRUCTIVE_OPT_IN=true` is explicitly set. +- `SOLR_BASE_URL` resolves to localhost / loopback; remote Solr endpoints are skipped. +- The disposable cluster reports at least two live nodes; the split test additionally confirms its new collection starts with one shard and the migration test confirms the target collection is empty. + +Run it from the GitHub Actions **CI** workflow using **Run workflow** and enable `enable_destructive_solrcloud_tests`. This provisions the workflow's ephemeral two-node SolrCloud cluster and runs the opt-in tests for the pinned Solr version matrix. Leaving the input disabled keeps both tests skipped, including on ordinary pull requests. + +The tests use asynchronous request IDs and poll `REQUESTSTATUS` for up to 180 seconds, then remove completed status records. They verify active child-shard state and post-split document visibility, and verify that every routed document is searchable in the migration target. They do not claim to test concurrent writes, rollback of a partially completed operation, or recovery after a cluster/node failure. + +If a request times out or fails, do not rerun against a reused cluster. Preserve the CI job logs, inspect `CLUSTERSTATUS` and `REQUESTSTATUS`, and discard the ephemeral cluster. Fixture cleanup deletes only the collections it created; if Solr still has an operation in flight and refuses deletion, destroy the ephemeral cluster rather than attempting cleanup against a shared server. The suite is an opt-in verification harness, not permission to point these destructive actions at a user-owned cluster. diff --git a/docs/api-inventory.json b/docs/api-inventory.json index a321008..55abdf1 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -84,12 +84,13 @@ "testFiles": [ "test/admin.test.ts", "test/integration/solr-collections.test.ts", + "test/integration/solrcloud-split-migrate-optin.test.ts", "test/collections-types.ts", "test/backup-restore.test.ts", "test/backup-restore-api-types.ts", "test/integration/solrcloud-backup-restore.test.ts" ], - "notes": "SolrCloud CREATE/LIST/DELETE, alias lifecycle, implicit-router shard lifecycle, named ADDREPLICA/DELETEREPLICA lifecycle, replica property add/delete and BALANCESHARDUNIQUE responses, plus multi-node MOVEREPLICA and REBALANCELEADERS leader reassignment are tested on pinned Solr 8.11.4, 9.11.0, and 10.1.0 images (CI #177, #192, #209, and PR #142 CI #224). MOVEREPLICA verifies the named replica becomes active on a different live node. REBALANCELEADERS assigns preferredLeader to an active non-leader and verifies it becomes shard leader. Snapshot, backup, and cluster-state response models remain unverified. Additional operation evidence: Typed SolrCloud collection backup/restore actions and asynchronous REQUESTSTATUS/DELETESTATUS tracking. Full backup, list, restore, query read-back, and delete cycle is integration-tested on Solr 8.11.4, 9.11.0, and 10.1.0 with a shared local filesystem volume mounted on both SolrCloud nodes. S3 and other external repositories are not certified. LISTALIASES defaults to SolrCollectionAliasesResponse; CREATEALIAS/DELETEALIAS default to SolrCollectionAliasResponse; CLUSTERSTATUS defaults to SolrClusterStatusResponse with extensible collection/shard/replica models. Filtered cluster status and unobserved version-specific fields remain optional/open. CREATESHARD/DELETESHARD now default to SolrCollectionShardOperationResponse and remain integration-tested on all pinned versions. SPLITSHARD has a typed V1 split.key contract (shard or routing-key mode), but remains runtime-unverified because it is long-running, hash-router-specific, and has disk/routing implications. MIGRATE is added with V1 collection/split.key/target.collection/forward.timeout/async parameters and the same response envelope; it remains runtime-unverified because it is destructive and requires a controlled compositeId source/target pair. Do not treat these typed requests as integration certification. RELOAD now has exact action-specific serialization and isolated collection integration coverage on pinned Solr 8.11.4/9.11.0/10.1.0. MODIFYCOLLECTION, ADDROLE, and REMOVEROLE now have dedicated serialization and consumer type fixtures; MODIFYCOLLECTION remains runtime-unverified because it mutates collection policy, while role actions remain runtime-unverified because they mutate cluster-wide node role assignments. Named response contracts now cover every action literal: LIST uses SolrCollectionListResponse; CREATE/DELETE/RELOAD/MODIFYCOLLECTION use SolrCollectionAdminResponse; replica lifecycle, movement, balancing, and property actions use SolrCollectionReplicaResponse; ADDROLE/REMOVEROLE use SolrCollectionRoleResponse. CollectionAdminResponse selects the default contract from the action literal while explicit caller generics remain supported. These are extensible response envelopes, not a claim that all action-specific payload fields or all actions are runtime-certified." + "notes": "SolrCloud CREATE/LIST/DELETE, alias lifecycle, implicit-router shard lifecycle, named ADDREPLICA/DELETEREPLICA lifecycle, replica property add/delete and BALANCESHARDUNIQUE responses, plus multi-node MOVEREPLICA and REBALANCELEADERS leader reassignment are tested on pinned Solr 8.11.4, 9.11.0, and 10.1.0 images (CI #177, #192, #209, and PR #142 CI #224). MOVEREPLICA verifies the named replica becomes active on a different live node. REBALANCELEADERS assigns preferredLeader to an active non-leader and verifies it becomes shard leader. Snapshot, backup, and cluster-state response models remain unverified. Additional operation evidence: Typed SolrCloud collection backup/restore actions and asynchronous REQUESTSTATUS/DELETESTATUS tracking. Full backup, list, restore, query read-back, and delete cycle is integration-tested on Solr 8.11.4, 9.11.0, and 10.1.0 with a shared local filesystem volume mounted on both SolrCloud nodes. S3 and other external repositories are not certified. LISTALIASES defaults to SolrCollectionAliasesResponse; CREATEALIAS/DELETEALIAS default to SolrCollectionAliasResponse; CLUSTERSTATUS defaults to SolrClusterStatusResponse with extensible collection/shard/replica models. Filtered cluster status and unobserved version-specific fields remain optional/open. CREATESHARD/DELETESHARD now default to SolrCollectionShardOperationResponse and remain integration-tested on all pinned versions. SPLITSHARD has a typed V1 split.key contract (shard or routing-key mode). MIGRATE uses V1 collection/split.key/target.collection/forward.timeout/async parameters and the same response envelope. Both now have opt-in asynchronous integration checks that require SOLR_DESTRUCTIVE_OPT_IN=true, a loopback endpoint, SOLR_CLOUD=true, SOLR_MULTINODE=true, and disposable collections; the manual workflow provisions its own two-node cluster. The tests verify child-shard state and document read-back after split, and target document read-back after migration. The opt-in workflow has not yet been executed, so both operations remain runtime-unverified. Abort, partial-failure recovery, and concurrent-write behavior are not certified. RELOAD now has exact action-specific serialization and isolated collection integration coverage on pinned Solr 8.11.4/9.11.0/10.1.0. MODIFYCOLLECTION, ADDROLE, and REMOVEROLE now have dedicated serialization and consumer type fixtures; MODIFYCOLLECTION remains runtime-unverified because it mutates collection policy, while role actions remain runtime-unverified because they mutate cluster-wide node role assignments. Named response contracts now cover every action literal: LIST uses SolrCollectionListResponse; CREATE/DELETE/RELOAD/MODIFYCOLLECTION use SolrCollectionAdminResponse; replica lifecycle, movement, balancing, and property actions use SolrCollectionReplicaResponse; ADDROLE/REMOVEROLE use SolrCollectionRoleResponse. CollectionAdminResponse selects the default contract from the action literal while explicit caller generics remain supported. These are extensible response envelopes, not a claim that all action-specific payload fields or all actions are runtime-certified." }, { "id": "update.commit", diff --git a/docs/collections-api-coverage.md b/docs/collections-api-coverage.md index 60e3b5c..fa27607 100644 --- a/docs/collections-api-coverage.md +++ b/docs/collections-api-coverage.md @@ -23,8 +23,8 @@ This matrix is deliberately action-specific. The public client infers a named re | `ADDREPLICAPROP` | `AddReplicaPropertyParams` | `SolrCollectionReplicaResponse` | Serialization and compile fixtures | Adds replica property; all three pinned versions | | `DELETEREPLICAPROP` | `DeleteReplicaPropertyParams` | `SolrCollectionReplicaResponse` | Serialization and compile fixtures | Removes replica property; all three pinned versions | | `CREATESHARD` | `CreateShardParams` | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Creates a shard on a disposable implicit collection and reads it through CLUSTERSTATUS; all three pinned versions | -| `DELETESHARD` | `DeleteShardParams` | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Deletes only the shard created by the test and verifies absence; all three pinned versions |\n| `MIGRATE` | `MigrateCollectionParams` (`collection`, V1 `split.key`, V1 `target.collection`, optional `forward.timeout`, `async`) | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Not integration-tested: data migration is destructive/long-running and requires a controlled compositeId source/target pair | -| `SPLITSHARD` | `SplitShardParams` (`shard` or V1 `split.key`; `ranges`, `numSubShards`, `splitMethod`, `async`) | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Not integration-tested: long-running, hash-router-only operation with disk and routing implications; unsuitable for shared CI collections | +| `DELETESHARD` | `DeleteShardParams` | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Deletes only the shard created by the test and verifies absence; all three pinned versions |\n| `MIGRATE` | `MigrateCollectionParams` (`collection`, V1 `split.key`, V1 `target.collection`, optional `forward.timeout`, `async`) | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Opt-in only: disposable compositeId source/empty target; async completion plus target document read-back. Not yet executed against pinned versions; remains runtime-unverified | +| `SPLITSHARD` | `SplitShardParams` (`shard` or V1 `split.key`; `ranges`, `numSubShards`, `splitMethod`, `async`) | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Opt-in only: disposable one-shard compositeId collection; async completion, active child-shard state, and document read-back. Not yet executed against pinned versions; remains runtime-unverified | | `ADDROLE` | `CollectionRoleParams` | `SolrCollectionRoleResponse` | Exact serialization and consumer type fixture | Not integration-tested: changes cluster-wide node role assignment; requires a dedicated isolated cluster policy fixture | | `REMOVEROLE` | `CollectionRoleParams` | `SolrCollectionRoleResponse` | Exact serialization and consumer type fixture | Not integration-tested: changes cluster-wide node role assignment; requires a dedicated isolated cluster policy fixture | | `BACKUP` | `CollectionBackupParams` | `SolrCollectionBackupResponse` | Named response type and compile-time API declarations | Async collection backup with shared storage; all three pinned versions | @@ -44,13 +44,14 @@ The public `CollectionAdminResponse` conditional type maps each action litera - Compile-time consumer examples: `test/admin-api-types.ts`, `test/collections-types.ts`. - Unit serialization: `test/admin.test.ts`. - Collection, alias, shard, replica, and cluster-status runtime checks: `test/integration/solr-collections.test.ts`. +- Destructive, opt-in SPLITSHARD and MIGRATE verification: `test/integration/solrcloud-split-migrate-optin.test.ts` (manual workflow dispatch only). - Backup/restore and async status runtime checks: `test/integration/solrcloud-backup-restore.test.ts`. - Pinned server matrix: `test/integration/fixtures.ts` and the SolrCloud jobs in the CI workflow. ## Gaps and scope boundaries 1. **Cluster status response typing:** `SolrClusterStatusResponse` now models the stable live-node, alias, collection, shard, and replica envelope. Unknown and version-specific fields remain open; it is not a closed schema for every SolrCloud response variation. -2. **Shard split and migration:** `SPLITSHARD` and `MIGRATE` have typed V1 parameter and response contracts plus serialization/type fixtures, but no shared-CI runtime test. Both remain explicitly unverified because they can be long-running and affect routing/data placement. `MIGRATE` additionally requires a compositeId router and a quiescent target collection. +2. **Shard split and migration:** `SPLITSHARD` and `MIGRATE` have typed V1 parameter and response contracts plus serialization/type fixtures, but no normal-CI runtime test. A manually dispatched, loopback-only two-node workflow now contains opt-in tests that poll asynchronous completion and verify cluster/data state; until that workflow passes for a pinned version, both actions remain explicitly runtime-unverified because they can be long-running and affect routing/data placement. `MIGRATE` additionally requires a compositeId router and a quiescent target collection. 3. **Collection role operations:** `ADDROLE` and `REMOVEROLE` share `CollectionRoleParams` and now have exact serialization and consumer type fixtures. They remain runtime-unverified because they mutate cluster-wide node role assignments. 4. **Collection `RELOAD` and `MODIFYCOLLECTION`:** `RELOAD` now has an isolated integration assertion on the pinned matrix. `MODIFYCOLLECTION` has serialization/type coverage but remains runtime-unverified because it mutates collection policy and behavior varies with SolrCloud configuration. 5. **Response contract boundaries:** `SolrCollectionAdminResponse`, `SolrCollectionReplicaResponse`, and `SolrCollectionRoleResponse` are intentionally extensible envelopes; they do not claim undocumented action-specific payload fields. `SolrCollectionListResponse` types the stable `collections` list. These contracts describe the client-side JSON shape, not blanket runtime certification for every action or Solr version. diff --git a/docs/endpoint-inventory.md b/docs/endpoint-inventory.md index 35c74ec..235df19 100644 --- a/docs/endpoint-inventory.md +++ b/docs/endpoint-inventory.md @@ -12,7 +12,7 @@ This inventory defines the boundary of the typed client API after review against | Schema API `/schema` | `schema()` | Typed schema command models; integration coverage includes field, field-type, dynamic-field, copy-field, and schema read operations. | | Config API `/config` | `config()`, `getConfig()`, `getConfigOverlay()` | Typed POST command models and typed GET effective-config/overlay responses. Integration coverage includes user-property mutation/read-back; broader component mutation coverage is tracked separately in [Issue #75](https://github.com/vsimko/node-solr-lambda/issues/75). | | CoreAdmin V1 `/solr/admin/cores` | `coreAdmin()` | Action-specific parameter models include STATUS, CREATE, RELOAD, UNLOAD, RENAME, SWAP, MERGEINDEXES, SPLIT, REQUESTSTATUS, and REQUESTRECOVERY. Integration coverage includes isolated CREATE, STATUS discovery, RELOAD, and cleanup UNLOAD; async status/recovery actions have serialization coverage only. UPGRADEINDEX is intentionally excluded from the cross-version union because it is not part of the pinned Solr 8.11.4 baseline and is an in-place, non-SolrCloud operation. | -| Collections V1 `/solr/admin/collections` | `collections()` | Action-specific input models cover the current action union. Backup/restore and async operations have named response models; other actions use caller-supplied generic response shapes. Per-action evidence and unverified operations are tracked in the [Collections action coverage matrix](collections-api-coverage.md). Other unmodelled actions include MIGRATE, FORCELEADER, and selected cluster/node management commands. | +| Collections V1 `/solr/admin/collections` | `collections()` | Action-specific input models cover the current action union. Backup/restore and async operations have named response models; other actions use caller-supplied generic response shapes. Per-action evidence and unverified operations are tracked in the [Collections action coverage matrix](collections-api-coverage.md). SPLITSHARD and MIGRATE have opt-in, loopback-only runtime checks in the manually dispatched workflow; they remain unverified until that workflow passes. Other unmodelled actions include FORCELEADER and selected cluster/node management commands. | | Security API `/solr/admin/authentication`, `/solr/admin/authorization` | `security()` | Authentication and authorization requests; dedicated security-enabled integration coverage is available on all three pinned versions. | | Ping `/admin/ping` | `ping()` | Typed client method; response status tested on all three pinned versions. | | System information `/solr/admin/info/system` | `systemInfo()` | Typed client method; representative response integration-tested on all three pinned versions. | diff --git a/test/integration/all.test.ts b/test/integration/all.test.ts index 96775af..be2a43a 100644 --- a/test/integration/all.test.ts +++ b/test/integration/all.test.ts @@ -1,4 +1,5 @@ import "./solr-collections.test.js"; +import "./solrcloud-split-migrate-optin.test.js"; import "./solr-config-security.test.js"; import "./solr-config-operations.test.js"; import "./solr-core-lifecycle.test.js"; diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index 0b8d7dd..2bceb86 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -234,9 +234,11 @@ export function integrationTest( export function cloudIntegrationTest( name: string, run: (fixture: SolrCloudIntegrationFixture) => Promise, - requiresMultipleNodes = false + requiresMultipleNodes = false, + requiresDestructiveOptIn = false ): void { - test(name, { skip: !baseUrl || !cloudEnabled || (requiresMultipleNodes && process.env.SOLR_MULTINODE !== "true") }, async () => { + const localEndpoint = baseUrl ? ["localhost", "127.0.0.1", "::1"].includes(new URL(baseUrl).hostname) : false; + test(name, { skip: !baseUrl || !cloudEnabled || (requiresMultipleNodes && process.env.SOLR_MULTINODE !== "true") || (requiresDestructiveOptIn && (process.env.SOLR_DESTRUCTIVE_OPT_IN !== "true" || !localEndpoint)) }, async () => { const client = new SolrClient({ baseUrl: baseUrl!, timeoutMs: 30_000 }); const createdCollections = new Set(); const createdAliases = new Set(); diff --git a/test/integration/solrcloud-split-migrate-optin.test.ts b/test/integration/solrcloud-split-migrate-optin.test.ts new file mode 100644 index 0000000..84c683b --- /dev/null +++ b/test/integration/solrcloud-split-migrate-optin.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { cloudIntegrationTest } from "./fixtures.js"; + +const routeKey = "tenant!"; + +async function waitForCollectionRequest(client: import("../../src/index.js").SolrClient, requestId: string): Promise { + for (let attempt = 0; attempt < 180; attempt += 1) { + const result = await client.collections("REQUESTSTATUS", { requestid: requestId }); + const state = result.status?.state; + if (state === "completed") { + await client.collections("DELETESTATUS", { requestid: requestId }); + return; + } + if (state === "failed" || state === "notfound") { + throw new Error(`Collection request ${requestId} ended in ${state}: ${JSON.stringify(result)}`); + } + await new Promise(resolve => setTimeout(resolve, 1_000)); + } + throw new Error(`Collection request ${requestId} did not complete within 180 seconds`); +} + +cloudIntegrationTest( + "SPLITSHARD splits an isolated compositeId collection and preserves routed documents", + async ({ client, createCollection, clientForCollection }) => { + const collection = await createCollection("integration_splitshard_optin"); + const statusBefore = await client.collections("CLUSTERSTATUS", { collection }); + const liveNodes = statusBefore.cluster?.live_nodes ?? []; + assert.ok(liveNodes.length >= 2, "opt-in verification requires at least two live SolrCloud nodes"); + const beforeShards = statusBefore.cluster?.collections?.[collection]?.shards ?? {}; + assert.equal(Object.keys(beforeShards).length, 1, "new disposable collection must start with exactly one shard"); + + const source = clientForCollection(collection); + const ids = [1, 2, 3].map(index => `${routeKey}split-${index}`); + await source.add(ids.map(id => ({ id, title: "splitshard opt-in verification" }))); + await source.commit({ waitSearcher: true }); + + const splitRequestId = `split-${randomUUID()}`; + const split = await client.collections("SPLITSHARD", { + collection, + "split.key": routeKey, + async: splitRequestId + }); + assert.equal(split.responseHeader?.status, 0, "SPLITSHARD should be accepted"); + await waitForCollectionRequest(client, splitRequestId); + + const statusAfter = await client.collections("CLUSTERSTATUS", { collection }); + const shardsAfter = statusAfter.cluster?.collections?.[collection]?.shards ?? {}; + assert.ok(Object.keys(shardsAfter).length >= 2, "cluster state should expose the split child shards"); + assert.ok( + Object.values(shardsAfter).filter(shard => shard.state === "active").length >= 2, + "at least two child shards should be active" + ); + const result = await source.query<{ response?: { numFound?: number } }>({ query: "*:*", fields: ["id"], limit: 10 }); + assert.equal(result.response?.numFound, ids.length, "all documents should remain searchable after the split"); + }, + true, + true +); + +cloudIntegrationTest( + "MIGRATE moves an isolated compositeId routing prefix into a disposable target collection", + async ({ client, createCollection, clientForCollection }) => { + const sourceName = await createCollection("integration_migrate_source_optin"); + const targetName = await createCollection("integration_migrate_target_optin"); + const status = await client.collections("CLUSTERSTATUS"); + assert.ok((status.cluster?.live_nodes?.length ?? 0) >= 2, "opt-in verification requires at least two live SolrCloud nodes"); + const source = clientForCollection(sourceName); + const target = clientForCollection(targetName); + const ids = [1, 2, 3].map(index => `${routeKey}migrate-${index}`); + + await source.add(ids.map(id => ({ id, title: "migration opt-in verification" }))); + await source.commit({ waitSearcher: true }); + + const targetBefore = await target.query<{ response?: { numFound?: number } }>({ query: "*:*", fields: ["id"], limit: 10 }); + assert.equal(targetBefore.response?.numFound, 0, "migration target must be empty before mutation"); + + const migrationRequestId = `migrate-${randomUUID()}`; + const migrated = await client.collections("MIGRATE", { + collection: sourceName, + "split.key": routeKey, + "target.collection": targetName, + "forward.timeout": 120, + async: migrationRequestId + }); + assert.equal(migrated.responseHeader?.status, 0, "MIGRATE should be accepted"); + await waitForCollectionRequest(client, migrationRequestId); + + let targetCount = 0; + for (let attempt = 0; attempt < 60; attempt += 1) { + const result = await target.query<{ response?: { numFound?: number } }>({ query: "*:*", fields: ["id"], limit: 10 }); + targetCount = result.response?.numFound ?? 0; + if (targetCount === ids.length) break; + await new Promise(resolve => setTimeout(resolve, 1_000)); + } + assert.equal(targetCount, ids.length, "target collection should contain every migrated routing-key document"); + const targetReadBack = await target.query<{ response?: { docs?: Array<{ id: string }> } }>({ query: "*:*", fields: ["id"], limit: 10 }); + const foundIds = new Set(targetReadBack.response?.docs?.map(document => String(document.id)) ?? []); + for (const id of ids) assert.ok(foundIds.has(id), `target collection should contain migrated document ${id}`); + }, + true, + true +); From f7914483957b0b2a083a3b4f2632570232e51890 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:40:55 +0100 Subject: [PATCH 13/22] docs(solrcloud): clarify opt-in runtime certification boundary --- docs/administration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/administration.md b/docs/administration.md index 2156c2e..a97f5f3 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -175,4 +175,4 @@ Run it from the GitHub Actions **CI** workflow using **Run workflow** and enable The tests use asynchronous request IDs and poll `REQUESTSTATUS` for up to 180 seconds, then remove completed status records. They verify active child-shard state and post-split document visibility, and verify that every routed document is searchable in the migration target. They do not claim to test concurrent writes, rollback of a partially completed operation, or recovery after a cluster/node failure. -If a request times out or fails, do not rerun against a reused cluster. Preserve the CI job logs, inspect `CLUSTERSTATUS` and `REQUESTSTATUS`, and discard the ephemeral cluster. Fixture cleanup deletes only the collections it created; if Solr still has an operation in flight and refuses deletion, destroy the ephemeral cluster rather than attempting cleanup against a shared server. The suite is an opt-in verification harness, not permission to point these destructive actions at a user-owned cluster. +If a request times out or fails, do not rerun against a reused cluster. Preserve the CI job logs, inspect `CLUSTERSTATUS` and `REQUESTSTATUS`, and discard the ephemeral cluster. Fixture cleanup deletes only the collections it created; if Solr still has an operation in flight and refuses deletion, destroy the ephemeral cluster rather than attempting cleanup against a shared server. The suite is an opt-in verification harness, not permission to point these destructive actions at a user-owned cluster. A workflow that is skipped or has not been manually dispatched is not runtime evidence; record successful Solr versions individually before updating compatibility status. From 483c41c18d662264f6930e34bdb6dfebdd3f688e Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:41:51 +0100 Subject: [PATCH 14/22] test(replication): allow local leader URLs across Solr versions --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e685bc4..270ccf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: --volume "$RUNNER_TEMP/solr-backups:/var/solr/backups" \ --publish 8983:8983 \ --env SOLR_JAVA_MEM="-Xms512m -Xmx512m" \ - --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true -Dsolr.security.allow.urls.enabled=false" \ + --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true -Dsolr.disable.allowUrls=true -Dsolr.security.allow.urls.enabled=false" \ "solr:${{ matrix.solr-version }}" \ solr-precreate integration - name: Wait for Solr and integration core @@ -134,7 +134,7 @@ jobs: --volume "$RUNNER_TEMP/solr-backups:/var/solr/backups" \ --publish 8983:8983 \ --env SOLR_JAVA_MEM="-Xms512m -Xmx512m" \ - --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true -Dsolr.security.allow.urls.enabled=false" \ + --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true -Dsolr.disable.allowUrls=true -Dsolr.security.allow.urls.enabled=false" \ --entrypoint /bin/bash \ "solr:${{ matrix.solr-version }}" \ -lc 'mkdir -p /var/solr/data && cp /tmp/security.json /var/solr/data/security.json && chmod 666 /var/solr/data/security.json && exec docker-entrypoint.sh solr-precreate integration' From 3e99948f32fa3fe73c4ecc9765b36ba77d5cbc9b Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:46:57 +0100 Subject: [PATCH 15/22] test(replication): enable qt dispatch for internal leader fetch --- test/integration/solr-replication-lifecycle.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/integration/solr-replication-lifecycle.test.ts b/test/integration/solr-replication-lifecycle.test.ts index fee2bf0..a56f06b 100644 --- a/test/integration/solr-replication-lifecycle.test.ts +++ b/test/integration/solr-replication-lifecycle.test.ts @@ -22,20 +22,24 @@ integrationTest("ReplicationHandler fetches a committed leader index into an iso const leaderUrl = `${baseUrl.replace(/\/$/, "")}/${leaderCore}/replication`; const documentId = createId("replication_lifecycle"); - await leader.config({ + const leaderConfig = await leader.config({ "update-requesthandler": { name: "/replication", class: "solr.ReplicationHandler", + handleSelect: true, customOptions: { leader: { replicateAfter: "commit" } } } }); - await follower.config({ + assert.equal(leaderConfig.responseHeader?.status, 0); + const followerConfig = await follower.config({ "update-requesthandler": { name: "/replication", class: "solr.ReplicationHandler", + handleSelect: true, customOptions: { follower: { leaderUrl, ...(username && password ? { httpBasicAuthUser: username, httpBasicAuthPassword: password } : {}) } } } }); + assert.equal(followerConfig.responseHeader?.status, 0); await leader.add([{ id: documentId, title: "leader follower lifecycle" }]); await leader.commit({ waitSearcher: true }); From 769b5154e94fe999ebe7c274aa3b68389af5205d Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:47:52 +0100 Subject: [PATCH 16/22] test(replication): use version-specific leader URL form --- test/integration/solr-replication-lifecycle.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/integration/solr-replication-lifecycle.test.ts b/test/integration/solr-replication-lifecycle.test.ts index a56f06b..23ae073 100644 --- a/test/integration/solr-replication-lifecycle.test.ts +++ b/test/integration/solr-replication-lifecycle.test.ts @@ -19,7 +19,9 @@ integrationTest("ReplicationHandler fetches a committed leader index into an iso }); const leader = makeClient(leaderCore); const follower = makeClient(followerCore); - const leaderUrl = `${baseUrl.replace(/\/$/, "")}/${leaderCore}/replication`; + const solrMajor = Number(process.env.SOLR_VERSION?.split(".")[0] ?? "0"); + // Solr 8 expects the handler URL; Solr 9+ appends /replication internally. + const leaderUrl = `${baseUrl.replace(/\/$/, "")}/${leaderCore}${solrMajor === 8 ? "/replication" : ""}`; const documentId = createId("replication_lifecycle"); const leaderConfig = await leader.config({ From b83f953fdd99d4396563b03e548b97597ba5a6ad Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:48:14 +0100 Subject: [PATCH 17/22] docs(replication): clarify version-specific leader URL form (docs/replication.md) --- docs/replication.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/replication.md b/docs/replication.md index 8ebb459..b0d2a5b 100644 --- a/docs/replication.md +++ b/docs/replication.md @@ -46,7 +46,7 @@ The `indexversion` response includes the current `generation` on newer Solr vers // Solr 10 uses fetchindex/abortfetch. Solr 8 and 9 use the legacy snappull/abort names. // leaderUrl may be omitted when configured on the follower. await client.replication("fetchindex", { - leaderUrl: "http://leader:8983/solr/catalog/replication", + leaderUrl: "http://leader:8983/solr/catalog", compression: "internal", httpConnTimeout: 5_000, httpReadTimeout: 10_000 @@ -59,7 +59,7 @@ await client.replication("enablepoll"); await client.replication("abortfetch"); ``` -`fetchindex` accepts the documented follower connection and compression settings. For Basic authentication between the follower and its leader, configure the ReplicationHandler's server-side follower settings; do not put credentials into URLs or query parameters. +`fetchindex` accepts the documented follower connection and compression settings. Solr 9+ expects `leaderUrl` as the core URL without the `/replication` suffix; Solr 8.11 expects the full handler URL. For Basic authentication between the follower and its leader, configure the ReplicationHandler's server-side follower settings; do not put credentials into URLs or query parameters. ## Control a leader From 9f9333d5f7dfbb61542aac30852e4fd54b826304 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:48:17 +0100 Subject: [PATCH 18/22] docs(replication): clarify version-specific leader URL form (test/replication-api-types.ts) --- test/replication-api-types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/replication-api-types.ts b/test/replication-api-types.ts index d5fb558..138d1e6 100644 --- a/test/replication-api-types.ts +++ b/test/replication-api-types.ts @@ -6,13 +6,13 @@ void client.replication("indexversion"); void client.replication("details"); void client.replication("filelist", { generation: 12 }); void client.replication("filelist", { indexversion: 12 }); -void client.replication("fetchindex", { leaderUrl: "http://leader:8983/solr/catalog/replication", compression: "internal" }); +void client.replication("fetchindex", { leaderUrl: "http://leader:8983/solr/catalog", compression: "internal" }); void client.replication("enablereplication"); void client.replication("disablereplication"); void client.replication("enablepoll"); void client.replication("disablepoll"); void client.replication("abortfetch"); -void client.replication("snappull", { leaderUrl: "http://leader:8983/solr/catalog/replication" }); +void client.replication("snappull", { leaderUrl: "http://leader:8983/solr/catalog" }); void client.replication("abort"); void client.replicationFile({ directoryType: "cf", filePath: "managed-schema", checksum: true }); From 8cb3bb671ab8e31297e19821a57c4f68df569e8c Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:48:19 +0100 Subject: [PATCH 19/22] docs(replication): clarify version-specific leader URL form (test/replication.test.ts) --- test/replication.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/replication.test.ts b/test/replication.test.ts index 1165174..9046b8e 100644 --- a/test/replication.test.ts +++ b/test/replication.test.ts @@ -14,7 +14,7 @@ test("ReplicationHandler commands use the configured core and JSON response form await client.replication("indexversion"); await client.replication("fetchindex", { - leaderUrl: "http://leader:8983/solr/catalog/replication", + leaderUrl: "http://leader:8983/solr/catalog", compression: "internal", httpConnTimeout: 5000, customParams: { maxWriteMBPerSec: 20 } @@ -27,7 +27,7 @@ test("ReplicationHandler commands use the configured core and JSON response form assert.equal(calls[0]!.url.searchParams.get("command"), "indexversion"); assert.equal(calls[0]!.url.searchParams.get("wt"), "json"); assert.equal(calls[1]!.url.searchParams.get("command"), "fetchindex"); - assert.equal(calls[1]!.url.searchParams.get("leaderUrl"), "http://leader:8983/solr/catalog/replication"); + assert.equal(calls[1]!.url.searchParams.get("leaderUrl"), "http://leader:8983/solr/catalog"); assert.equal(calls[1]!.url.searchParams.get("compression"), "internal"); assert.equal(calls[1]!.url.searchParams.get("httpConnTimeout"), "5000"); assert.equal(calls[1]!.url.searchParams.get("maxWriteMBPerSec"), "20"); @@ -54,7 +54,7 @@ test("ReplicationHandler supports read, poll, and replication lifecycle commands await client.replication("enablepoll"); await client.replication("disablepoll"); await client.replication("abortfetch"); - await client.replication("snappull", { leaderUrl: "http://leader:8983/solr/catalog/replication" }); + await client.replication("snappull", { leaderUrl: "http://leader:8983/solr/catalog" }); await client.replication("abort"); assert.deepEqual(commands, [ From 3338c6bbf0348b50c62b2cb79e159d5889d92906 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:49:32 +0100 Subject: [PATCH 20/22] types(config): expose request-handler handleSelect (src/types.ts) --- src/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types.ts b/src/types.ts index 249a2fd..28807a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1102,6 +1102,8 @@ export interface SolrConfigPlugin { customOptions?: Readonly>; } export interface SolrRequestHandlerConfig extends SolrConfigPlugin { + /** Allow the handler to be selected through the legacy `qt` request parameter. */ + handleSelect?: boolean; defaults?: SolrConfigRequestHandlerParams; appends?: SolrConfigRequestHandlerParams; invariants?: SolrConfigRequestHandlerParams; From 09cf69b833f927fea103ee511a2030d104469e61 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:49:36 +0100 Subject: [PATCH 21/22] types(config): expose request-handler handleSelect (test/admin-api-types.ts) --- test/admin-api-types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/admin-api-types.ts b/test/admin-api-types.ts index 24cb2cb..ce7814c 100644 --- a/test/admin-api-types.ts +++ b/test/admin-api-types.ts @@ -192,6 +192,7 @@ const invalidConfigValue: SolrConfigCommand = { "set-property": { "query.maxBool // @ts-expect-error unset-property only accepts known common Config API property names. const invalidUnsetProperty: SolrConfigCommand = { "unset-property": "query.notARealSetting" }; void [invalidConfigProperty, invalidConfigValue, invalidUnsetProperty]; +const handleSelectHandler: SolrConfigCommand = { "add-requesthandler": { name: "/replication", class: "solr.ReplicationHandler", handleSelect: true } }; const addHandler: SolrConfigCommand = { "add-requesthandler": { name: "/select", @@ -214,6 +215,7 @@ const addInitParams: SolrConfigCommand = { "add-initparams": { name: "shared-search-params", path: ["/select", "/query"], defaults: { rows: 20, wt: "json" } } }; void client.config(setProperty); +void client.config(handleSelectHandler); void client.config(addHandler); void client.config(addListener); void client.config(addInitParams); From cf7d70e9196317a3abf0082ae65b2cac401de2a4 Mon Sep 17 00:00:00 2001 From: Viliam Simko Date: Wed, 4 Nov 2026 07:49:38 +0100 Subject: [PATCH 22/22] types(config): expose request-handler handleSelect (docs/config.md) --- docs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index ad65901..e1b53f6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -17,7 +17,7 @@ await solr.config({ }); ``` -Typed command families include set/unset properties, request handlers, search components, query response writers, init params, query parsers, value-source parsers, transformers, update processors, query converters, listeners, and expressible components. `SolrRequestHandlerConfig` exposes typed `defaults`, `appends`, and `invariants` through `SolrConfigRequestHandlerParams`; common request parameters such as `q`, `fq`, `fl`, `rows`, `start`, `sort`, `wt`, `echoParams`, and time limits receive value-aware autocomplete, while the parameter map remains open for Solr extensions. Read models distinguish request handlers, search components, response writers, listeners, and init-params sets; response writers expose `contentType`/`charset`, and listeners expose standard event names with custom plugin event names still accepted. Cache settings are configured through `set-property` (for example, `query.filterCache.size`), not through a separate cache command. Search-component option maps and other plugin-defined component settings remain open; `customOptions` is the explicit flattened escape hatch for plugin-specific command properties. The library does not claim to validate every plugin's schema. Check the documentation for the target Solr version before applying configuration changes. +Typed command families include set/unset properties, request handlers, search components, query response writers, init params, query parsers, value-source parsers, transformers, update processors, query converters, listeners, and expressible components. `SolrRequestHandlerConfig` exposes typed `handleSelect` plus `defaults`, `appends`, and `invariants` through `SolrConfigRequestHandlerParams`; common request parameters such as `q`, `fq`, `fl`, `rows`, `start`, `sort`, `wt`, `echoParams`, and time limits receive value-aware autocomplete, while the parameter map remains open for Solr extensions. Read models distinguish request handlers, search components, response writers, listeners, and init-params sets; response writers expose `contentType`/`charset`, and listeners expose standard event names with custom plugin event names still accepted. Cache settings are configured through `set-property` (for example, `query.filterCache.size`), not through a separate cache command. Search-component option maps and other plugin-defined component settings remain open; `customOptions` is the explicit flattened escape hatch for plugin-specific command properties. The library does not claim to validate every plugin's schema. Check the documentation for the target Solr version before applying configuration changes. ## Common properties and response types