fix(search): accept index schemas whose analyzers and CORS options are objects - #2624
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
Hey Rana Singh (@ranadeepsingh) 👋! We use semantic commit messages to streamline the release process. Examples of commit messages with semantic prefixes:
To test your commit locally, please follow our guild on building from source. |
There was a problem hiding this comment.
Pull request overview
Fixes Azure AI Search index JSON deserialization so indexes that define object-valued schema features (custom analyzers/filters/tokenizers/suggesters and corsOptions) can be validated and written to without spray.json.DeserializationException. This targets SynapseML’s Search service integration (cognitive/services/search) where index JSON is parsed/validated before any document upload.
Changes:
- Update
IndexInfoto treatsuggesters, analyzer/tokenizer/filter collections, andcorsOptionsas opaqueJsValuepass-through fields to match the Search REST API’s object shapes. - Add a focused regression suite (
IndexSchemaParsingSuite) covering parsing and round-trip preservation for the affected schema members (including the #2143 repro). - Register the new suite in
pipeline.yamlunder themisctest leg to satisfy pipeline test coverage expectations.
Show a summary per file
| File | Description |
|---|---|
cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchSchemas.scala |
Changes IndexInfo field types to JsValue/Seq[JsValue] to avoid failing on object-valued schema members returned by the Search API. |
cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaParsingSuite.scala |
Adds regression tests that validate parsing of object-valued analyzers/tokenizers/filters/suggesters and object corsOptions, including the issue repro payload. |
pipeline.yaml |
Includes the new parsing suite in the CI test matrix (misc leg). |
Review details
Tip
Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Lite
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2624 +/- ##
=======================================
Coverage 87.05% 87.05%
=======================================
Files 338 338
Lines 18843 18843
Branches 1805 1818 +13
=======================================
Hits 16403 16403
Misses 2440 2440 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Fixed a real failure this PR introduced, caught by the That check replays the PR as a patch onto the \\scala My Writing Re-validated: /azp run |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaParsingSuite.scala:46
- This test asserts JSON AST equality for the analyzer payload, not a byte-identical string round-trip. Updating the test name/comment avoids implying stronger guarantees than are actually being checked (which could confuse future debugging).
test("a custom analyzer survives a parse and re-serialize round trip") {
val original = indexJson(customAnalyzer)
val roundTripped = parseIndexJson(original).toJson.asJsObject
// The service rejects an analyzer it cannot identify, so every member has to come back intact.
assert(roundTripped.fields("analyzers") == original.parseJson.asJsObject.fields("analyzers"))
cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchSchemas.scala:22
- The comment reads ungrammatically ("replayed onto import ..."). Rewording will make the rationale clearer for future maintainers.
// spray.json.JsValue is written out in full because the release branches this change is replayed
// onto import spray.json explicitly rather than by wildcard.
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
End-to-end validation against the live Azure Search serviceThe offline What the live service actually returnsI created an index on
That matches this PR exactly: five fields typed One incidental finding worth recording: a suggester's A/B proof that this is a real fix, not a cosmetic retypeReverting
So the failure reproduces on genuine service output, and this change is what fixes it. New regression testAdded It's registered in the Verification runAll test indexes created during this work have been deleted; the service is back to its original 12 indexes. |
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Review details
Suppressed comments (1)
cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaLiveRoundTripSuite.scala:93
- The cleanup in the
finallyblock can mask the real test failure:deleteIndexcallssafeSend, which throws on non-2xx responses. If the test body fails (e.g., parse assertion) and deletion also fails (transient network/429/403), the thrown exception fromdeleteIndexwill override the original failure and make debugging harder. WrapdeleteIndexin a best-effort try/catch (or allow expected codes like 404) so the primary assertion failure is preserved.
} finally {
deleteIndex(indexName)
}
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Lite
…e objects
`IndexInfo` declared six pass-through members as `Option[Seq[String]]`, but the
Azure AI Search REST API returns objects for all of them. Any index carrying a
custom analyzer, char filter, tokenizer, token filter, suggester, or CORS
configuration therefore failed to deserialize, and it failed before a single
document was sent:
spray.json.DeserializationException: Expected String as JsString, but got
{"@odata.type":"#Microsoft.Azure.Search.CustomAnalyzer",...}
`IndexInfo` is parsed twice out of the caller-supplied `indexJson` -- at
`AzureSearchAPI.scala:208` (`parseIndexJson(indexJson).name.get`, the first
statement of `createIfNoneExists`) and again in `validateIndexInfo` -- so the
write threw straight out of `AzureSearchWriter.write` regardless of apiVersion
and regardless of whether the index already existed.
The writer never inspects any of these members; it only round-trips them to the
service. Typing them as opaque `JsValue` fixes the deserialization and also
stops the library from breaking again when the service introduces a new analyzer
or tokenizer kind. Nothing reads the fields, and the single construction site in
`AzureSearch.scala:260` passes `None` for all of them, so this is source
compatible.
Fixes microsoft#2143
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Release Branch Compatibility Check replays this PR as a patch onto the
spark4.1 branch, which imports spray.json explicitly
(`import spray.json.{DefaultJsonProtocol, JsonFormat, RootJsonFormat}`) rather
than by wildcard as master does. The IndexInfo hunk does not touch the import
block, so JsValue resolved on master but not after replay, failing the check
with six "not found: type JsValue" errors.
Writing spray.json.JsValue in full makes the hunk self-contained and immune to
import drift on any release branch, without adding an import hunk that would
itself conflict against the differing import block.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… fields The existing IndexSchemaParsingSuite asserts against hand-written JSON, so it proves the parser accepts the shapes reported in microsoft#2143 but not that those are the shapes the service actually emits. This adds an end-to-end companion that creates a real index exercising all six object-valued schema features (suggesters, analyzers, tokenizers, tokenFilters, charFilters, corsOptions), reads it back through the production getIndexJsonFromExistingIndex path used by AzureSearchWriter, and parses it with parseIndexJson. The index is deleted in a finally block. Verified against the live mmlspark-azure-search service on API version 2026-04-01: the service returns five of the fields as arrays of objects and corsOptions as a single object, matching this PR premise. Parsing the captured live payload with the pre-fix String types fails with "DeserializationException: Expected String as JsString, but got {\"name\":\"sml_suggester\",...}", which is exactly issue microsoft#2143; with the JsValue types it succeeds. Registered in pipeline.yaml misc leg so PipelineTestCoverageSuite passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
0aeacea to
9884920
Compare
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
Brendan Walsh (BrendanWalsh)
left a comment
There was a problem hiding this comment.
Approved — automated multi-model review cycle.
Reviewed by Claude Opus 5 and GPT-5.6 Sol (both at maximum reasoning effort) against a blocking-issues-only rubric, followed by an independent adjudication pass. Final result: 0 blocking findings on commit 9884920c. All 30 checks are green.
One finding was raised and was overturned on verification:
"Preserve the published IndexInfo field types" (AzureSearchSchemas.scala:26) — not blocking.
The concern was that retyping suggesters/analyzers/charFilters/tokenizers/tokenFilters to Option[Seq[JsValue]] and corsOptions to Option[JsValue] is a source-compatibility break for downstream Scala consumers. The supporting evidence was mis-cited: VectorSchemaMigrationSuite.scala:188 locks down VectorSearch and IndexField only and asserts nothing whatsoever about IndexInfo. Beyond that:
- There is no MiMa or equivalent binary/source compatibility enforcement in this repo (
project/plugins.sbthas none; zero hits inbuild.sbt). IndexInfoappears in zero docs and zero notebooks; a repo-wide grep finds zero accesses to any of the six retyped members.- No working downstream consumer can exist in the first place, because the old
Seq[String]typing threw on every real Azure AI Search payload — which is precisely the bug this PR fixes.
Backward compatibility on the read path was confirmed decisively: spray-json 1.3.5's AdditionalFormats.JsValueFormat.read(value) = value is a total identity read, so schemas that previously parsed as plain strings still parse (JsString is a JsValue). jsonFormat11 still derives, Option fields still omit on None, and prepareEntity sends aligned.compactPrint of the raw JSON rather than a re-serialized IndexInfo, so the wire format is untouched. Validation is unweakened — validateIndexInfo only checks name and the per-field rules, which this PR does not modify.
Non-blocking observations, recorded but not gating:
IndexSchemaLiveRoundTripSuite.scala:78reads the index back immediately after creation withoutretryWithBackoff, unlike all six existing call sites, and themiscleg carries noFLAKY: "true". Azure AI Search index creation is eventually consistent, so this can flake. Mitigated in practice byretryCountOnTaskFailure: 1on the Unit Test task, which is why it is not gating.IndexSchemaLiveRoundTripSuite.scala:92callsdeleteIndexin afinallyviasafeSend, which defaults toexpectedCodes = Set()and therefore throws on a non-2xx cleanup — that would mask the real assertion failure in a compound-failure scenario.
a62b893
into
microsoft:master
Why
Fixes #2143. An Azure AI Search index that uses a custom analyzer (or char filter, tokenizer, token filter, suggester, or CORS configuration) could not be written to at all:
Reported against 0.11.0 and still reproducible on
master.Root cause
IndexInfodeclared six members asOption[Seq[String]], but the Search REST API returns objects for every one:suggestersOption[Seq[String]]analyzersOption[Seq[String]]charFiltersOption[Seq[String]]tokenizersOption[Seq[String]]tokenFiltersOption[Seq[String]]corsOptionsOption[Seq[String]]The right-hand column was confirmed against the live service, not inferred from the issue.
IndexInfois deserialized twice from the caller'sindexJson, both before any document is sent —parseIndexJson(...).name.getat the top ofcreateIfNoneExists, andvalidateIndexInfo. So the write threw straight out ofAzureSearchWriter.writeregardless ofapiVersionor whether the index already existed.The fix
Type the six members as opaque
JsValue.The writer never inspects them — it only round-trips them to the service — so
JsValueis both sufficient and strictly more robust: it won't break again when the service adds a new analyzer or tokenizer kind. Same class of defect as the standingTODOaboutsynonymMaps.In-repo compatibility: nothing reads these fields (verified by grep), and the only construction site passes
Nonefor all six. Existing callers compile unchanged, and serialized output is byte-identical sinceNonefields are omitted.IndexInfois a public case class returned by the publicIndexParser.parseIndexJson, so retyping changes itsapply/copy/accessor signatures. Real exposure is near zero — under the old typing any index carrying these features threw on read, so no working downstream code could have depended on theSeq[String]shape. There is no MiMa gate here, so CI won't flag it; noting it so it can be captured in the next release notes.Tests
IndexSchemaParsingSuite— 7 offline tests driven by the exact index definition from #2143: custom analyzer parses; custom analyzer survives parse → re-serialize byte-identically (the service rejects an analyzer it can't identify, so round-trip fidelity is the property that matters); object-valuedcharFilters/tokenizers/tokenFiltersparse; object-valuedsuggestersparse;corsOptionsparses as an object not an array; all six at once parse; omitting them yields empty rather than failure. Every one fails onmasterwithExpected String as JsString.IndexSchemaLiveRoundTripSuite— 1 end-to-end test. The offline suite asserts against hand-written JSON, so alone it proves the parser accepts these shapes, not that they're what the service emits. This creates a real index using all six features, reads it back through the same productiongetIndexJsonFromExistingIndexpath the writer uses, parses it, and deletes the index in afinally. ~21s.pipeline.yamlregisters both in themiscleg —PipelineTestCoverageSuitefails the build otherwise, since nothing globs theservices.searchparent package.Proving it's a real fix, not a cosmetic retype
Reverting
AzureSearchSchemas.scalafails at compile time (the tests use the new types), which shows type-safety but not the runtime bug. So a payload captured live frommmlspark-azure-search(API2026-04-01) was parsed throughparseIndexJsonalone under both schema versions:master'sOption[Seq[String]]DeserializationException: Expected String as JsString, but got {"name":"sml_suggester",...}— verbatim #2143JsValueIncidental finding worth recording: a suggester's
sourceFieldscannot reference a field using a custom analyzer — the service returns 400. The test points its suggester at a separate default-analyzer field.Validation
IndexSchemaParsingSuite7/7 ·IndexSchemaLiveRoundTripSuitepasses live ·PipelineTestCoverageSuitepasses ·cognitive/scalastyle+Test/scalastyle0 errors · full ADO pipeline 77/77 green, 0 failures, including theUnitTests miscleg that runs the live suite.Checklist