Skip to content

refactor(indexing): forward CSV and XML to Solr's own update handlers - #205

Open
adityamparikh wants to merge 4 commits into
apache:mainfrom
adityamparikh:fix/xml-record-mapping
Open

adityamparikh wants to merge 4 commits into
apache:mainfrom
adityamparikh:fix/xml-record-mapping

Conversation

@adityamparikh

@adityamparikh adityamparikh commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Solr already parses CSV and its own update XML. The server's CSV and XML document creators re-implemented that, and the XML one did it with a convention of its own: a generic <shows><show> mapping that prefixed every field with the record element (show_title) and dropped the id, so XML was the one format whose documents did not match the same data in JSON, CSV or Markdown.

Both creators are removed. The two tools now forward the payload, as given, to Solr's own update handlers:

  • index-csv-documents sends the CSV to Solr's CSV handler with header=true. Solr reads the header for the field names, so column names are used as given (no lower-casing or underscoring). Repeated column names are multi-valued fields and empty cells are skipped. The server does not parse the payload at all; commons-csv is dropped.
  • index-xml-documents takes Solr's update XML, <add><doc><field name="id">…</field><field name="genres">a</field><field name="genres">b</field></doc></add>, the format every Solr user already has, and sends it as-is.

The <add> guard

Solr's update XML grammar is a command language: <delete>, <commit>, <optimize> and <rollback> go to the same endpoint as <add> (Ref Guide: Indexing with Update Handlers). A tool that forwarded blindly would let an indexing call delete a collection. SolrJ only writes update XML and Solr's own XMLLoader lives in solr-core, so the check has to be local: SolrUpdateXml.requireAddBlock reads the payload with a hardened StAX parser (DTD off, external entities off) only as far as the root element, rejects a DOCTYPE outright, and rejects any root but <add>. Nothing past the root is inspected; Solr parses the body and accepts or rejects it as a whole.

The parser is Spring's StaxUtils.createDefensiveInputFactory(), which sets those two properties and additionally installs a no-op XMLResolver. It is held in a static field, since XMLInputFactory.newFactory() runs a ServiceLoader scan of the whole classpath on every call and the factory is never reconfigured after construction.

What the tools report

Solr's update response carries a status and a QTime but no document count, so the two tools no longer claim "indexed N of N". They report that Solr accepted and committed the payload, with status and QTime, and the index-data prompt points at check-health / *:* for the count.

The commit rides on the update request itself (setAction(ACTION.COMMIT)) rather than following as a second POST, so the status and QTime reported cover the commit the message claims.

What this removes

XmlDocumentCreator, CsvDocumentCreator, their two Spring test classes, the orchestrator's CSV/XML paths and the commons-csv dependency: about 500 lines of parsing and mapping. What remains is SolrUpdateXml (about 30 lines) with a plain unit test, unit tests on the service, and real-Solr integration tests that index CSV and XML through the tools and query the documents back, including the rejected <delete>.

ShowsSampleDataIntegrationTest indexes the same 61 shows through all three tools into three collections and asserts the retrieved documents are field-for-field identical, so a tutorial step written against one format holds for the others.

Native image

The sample-data fixtures are registered for the native test binary through a TestRuntimeHintsRegistrar wired via src/test/resources/META-INF/spring/aot.factories, rather than a -H:IncludeResources option in the Gradle args list — the seam dev-docs/graalvm-native-image.md prescribes. It lives in test sources because SolrNativeHints is the production equivalent and must not ship test fixture names in the application image.

The patterns name the three fixtures rather than matching by extension: registerPattern compiles * to .*, which crosses /, so a *.xml there would embed all 124 XML resources on this project's 210-jar test classpath. Spring AOT registers .*\.json globally, so shows.json was already covered while its CSV and XML siblings were not; registering all three keeps the dataset covered in one place.

Behaviour changes

  • CSV column names and XML <field name> values are used as given; neither is sanitized any more (JSON and Markdown still are).
  • Generic element-per-field XML is no longer accepted (it was never a Solr format).
  • Solr accepts or rejects a CSV or XML payload as a whole; the per-document retry that produced "60 of 61" applies to JSON and Markdown only, and CSV/XML responses no longer state a count.
  • A CSV or XML index is one request to Solr instead of two.

🤖 Generated with Claude Code

https://claude.ai/code/session_014XuwC2kdJ6Q1dDDZMHDCee

@adityamparikh adityamparikh changed the title fix(indexing): XML record elements are wrappers, not field prefixes refactor(indexing): forward CSV and XML to Solr's own update handlers Sep 14, 2026
Solr already parses CSV and its own update XML. The server's CSV and XML
document creators re-implemented that, and the XML one did it with a
convention of its own: a generic <shows><show> mapping that prefixed
every field with the record element (show_title) and dropped the id, so
XML was the one format whose documents did not match the same data in
JSON, CSV or Markdown.

Both creators are removed, along with the orchestrator's CSV/XML paths
and the commons-csv dependency. The two tools now forward the payload,
as given, to Solr's /update handler:

- index-csv-documents sends the CSV with header=true. Solr reads the
  header for the field names, so column names are used as given.
  Repeated column names are multi-valued fields and empty cells are
  skipped.
- index-xml-documents takes Solr update XML, <add><doc><field
  name="...">, the format every Solr user already has.

Solr's update XML grammar is a command language: <delete>, <commit>,
<optimize> and <rollback> go to the same endpoint as <add>, so a tool
that forwarded blindly would let an indexing call delete a collection.
SolrUpdateXml.requireAddBlock reads the payload with a hardened StAX
parser (DTD off, external entities off) only as far as the root element
and rejects a DOCTYPE or any root but <add>; Solr parses the rest.

Solr's update response carries a status and a QTime but no document
count, so the two tools report that Solr accepted and committed the
payload instead of an invented "indexed N of N", and the index-data
prompt points at the health check for the count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJSxr89SRAa7BTC8Jg27Rj
Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
adityamparikh and others added 3 commits September 14, 2026 13:15
…egration tests

Port shows.csv, shows.xml, and ShowsSampleDataIntegrationTest from PR apache#201.
The integration test verifies that JSON, CSV, and XML datasets index the
exact same 61 documents into Solr.

Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
Co-authored-by: Junie <junie@jetbrains.com>
…nd trip

Follow-up cleanup on the CSV/XML pass-through, no behaviour change beyond
the commit folding below.

Reuse:
- The XML content type was spelled out as a literal; SolrJ ships it as
  ClientUtils.TEXT_XML with exactly that value. (The CSV side keeps its
  literal: ContentStreamBase.TEXT_CSV is private.)
- SolrUpdateXml configured an XMLInputFactory by hand. Spring's
  StaxUtils.createDefensiveInputFactory() sets the same two properties and
  additionally installs a no-op XMLResolver, so it is strictly more
  hardened. It is now a static field: XMLInputFactory.newFactory() runs a
  ServiceLoader scan of the whole classpath on every call, and the factory
  is never reconfigured after construction, which is the sharing contract
  StAX requires.
- IndexingServiceIntegrationTest is a @SpringBootTest that overwrote its
  own @Autowired beans with hand-built ones, under a comment claiming it
  is not a Spring Boot test. IndexingService is @observed, so the test was
  exercising an unproxied object rather than the one the application runs.

Efficiency:
- forward() posted the payload and then posted a separate commit. The
  commit now rides on the same request via setAction(ACTION.COMMIT), which
  removes a round trip per CSV/XML call and makes the status and QTime the
  message reports actually cover the commit it claims. The two mock tests
  that pinned the second call now assert commit=true on the request.

Simplification:
- describeIndexedFields/describeFieldNames was split when three tools
  reported field names; only the JSON tool does now, so it is one method
  again. The indexDocuments javadoc that had drifted onto a constant is
  reattached.
- SolrUpdateXml's ClosingReader record existed only to make one
  XMLStreamReader try-with-resources-able; the source is an in-memory
  StringReader and XMLStreamReader.close() does not close it.
- Removed the emptied "Apache Commons" heading left by dropping
  commons-csv.

Docs that still described the deleted parsers: IndexingDocumentCreator's
class javadoc, the test tree in dev-docs/ARCHITECTURE.md, and the FAQ's
claim that CSV and XML get field sanitization and 10 MB guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XuwC2kdJ6Q1dDDZMHDCee
Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
The sample data was made readable from the native test binary with a raw
hosted option in the Gradle args list:

    -H:IncludeResources=shows\.(csv|xml)$

That is the wrong seam and the wrong scope. dev-docs/graalvm-native-image.md
says to add a targeted registration through a hints registrar and prefer it
over other mechanisms "so the rule is explicit and reviewed", so the next
person adding a fixture does not find the precedent where the docs point.
And the rule being encoded is not the name of one dataset.

Replaced with a TestRuntimeHintsRegistrar wired through
src/test/resources/META-INF/spring/aot.factories, which spring-test invokes
for every test class. It lives in test sources deliberately: SolrNativeHints
is the production equivalent and must not ship test fixture names in the
application image.

The patterns name the three fixtures rather than matching by extension.
registerPattern compiles * to .*, which crosses /, so a *.xml here would
embed all 124 XML resources present on this project's 210-jar test
classpath, declaring every dependency's XML to be a test fixture.

Registering shows.json alongside its siblings is not redundant: Spring AOT
registers .*\.json globally, so the JSON fixture was covered by the
framework while the CSV and XML ones were not. Covering all three in one
place makes the dataset intentional rather than two-thirds accidental.

Verified with ./gradlew nativeTest -Pnative: 244 passed, 0 failed, 142
skipped (skips unchanged from baseline), with
ShowsSampleDataIntegrationTest executing natively. A negative control with
the registrar unregistered fails that test alone, on
"NullPointerException: missing test resource /shows.csv", confirming the
registration is load-bearing and not a no-op replacing an unnecessary flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XuwC2kdJ6Q1dDDZMHDCee
Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
adityamparikh added a commit to adityamparikh/solr-mcp that referenced this pull request Sep 15, 2026
…rompt too

Rebased onto apache#205, this branch now owns forward(), the CSV/XML path that PR
apache#205 introduces. Extending the soft commit to it is what this PR set out to do:
'a caller that splits a large input across several calls does not pay for a
hard commit per call' was only true for JSON and markdown while CSV and XML
still issued setAction(COMMIT, waitFlush=true, waitSearcher=true).

Measured against Solr directly (same endpoint, same document, median of 10):
hard commit 22.79 ms vs soft 8.26 ms. Over the MCP tools, 61 single-document
CSV calls go from 1853 ms to 585 ms.

Also carries the payload-splitting and format guidance into the index-data
prompt, so prompt-driven clients get the same advice as clients that read the
server instructions.

Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adityamparikh added a commit to adityamparikh/solr-mcp that referenced this pull request Sep 15, 2026
… into

The instructions read 'Send data in the format you already have; when you must
convert, emit CSV, and never convert to XML.' The two clauses sit next to each
other, so a model holding XML can read 'never XML' as applying to its input and
convert it to CSV -- the most expensive thing it could do, because apache#205
forwards XML straight to Solr's update handler with no parsing on our side.

Input format and conversion target are now stated separately: call the tool
matching the format you already have, never rewrite XML you were given, and
convert only when the source format has no tool of its own (a pasted table,
TSV, YAML), in which case convert to CSV.

Only index-markdown-documents carried an as-is rule; the other three said
nothing, and the positive rule lived solely in the server instructions, which
not every client puts in the model's context. BATCH_GUIDANCE now carries it on
all four tools.

Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adityamparikh added a commit to adityamparikh/solr-mcp that referenced this pull request Sep 15, 2026
… searchable

Every indexing tool ended its work with a hard commit: indexDocuments called
solrClient.commit(collection), and the CSV/XML path added by apache#205 issued
setAction(COMMIT, waitFlush=true, waitSearcher=true). A hard commit fsyncs the
segments, so each tool call waited on the storage device.

That is not what Solr's own defaults do. The _default configset ships
autoCommit at maxTime 15000 with openSearcher=false, and autoSoftCommit at
3000: a background hard commit purely to truncate the transaction log, and soft
commits for visibility. Forcing a synchronous fsync per tool call fought that
design.

Both paths now commit with waitFlush=false, waitSearcher=true, softCommit=true.
waitSearcher keeps the guarantee that matters to a tool caller: the documents
are searchable the moment the call returns. Verified 30/30 with zero delay
across the JSON and CSV paths.

Durability is unchanged. The transaction log is written on the add, before any
commit, so documents survive a crash regardless of commit mode; a hard commit
governs how much tlog must be replayed on recovery, not whether data is lost.
That housekeeping stays with autoCommit.

Measured against Solr, 20 interleaved reps, same endpoint and document, only
the commit parameter varying:

  no commit      4.05 ms median
  soft commit    8.61 ms median, p90 10.65
  hard commit   18.94 ms median, p90 41.32

2.2x faster and far tighter -- the hard commit's p90 is four times its median,
which is fsync variance. Over the MCP tools, 61 single-document CSV calls go
from 1853 ms to 585 ms.

Operators running a custom configset with autoCommit disabled should enable it,
or the transaction log grows until something else commits.

Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant