fix(index): a Text field on a numeric column no longer makes the contentlet unindexable (#37272) - #37463
fix(index): a Text field on a numeric column no longer makes the contentlet unindexable (#37272)#37463fabrizzio-dotCMS wants to merge 4 commits into
Conversation
…xable (#37272) Issue-resolution spec only (PR 1 of 2). No implementation. loadFields picks the serialization branch by storage column name rather than by the value, so a Text field whose value is a String but whose column is numeric reaches DecimalFormat.format() and throws. The per-field catch logs at WARN and then rethrows, so one bad field discards the whole contentlet. Both defects date to the initial trunk import (2012); the ES->OpenSearch work only made them visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pec (#37272) Addresses the four points raised in review of #37393, plus one they surfaced indirectly and which turned out to be the more consequential of the two. The _dotraw key, not the numeric key, is load-bearing. loadFields writes the padded string to <field>_text (:1009, :1118); that entry is lowercased (:590) and renamed _text -> _dotraw (:598), and the _text key is then dropped by default (:607-610), so _dotraw is the only survivor. It is mapped keyword, and addBuilderSort appends _dotraw to every sort field (ContentFactoryIndexOperationsES:412-413). The zero-padding is therefore the only reason ordering by a numeric field works. The previous AC-007 wrote the raw text there, which silently reorders any listing sorted by that field. Changes: - AC-007 is now the _dotraw invariant: padded or absent, never raw text, with an end-to-end sort assertion. Forced by the mapping, not a design choice. - AC-008 decides the numeric key separately: omit rather than emit 0, since a 0 is indistinguishable from a genuine 0 in ranges, sorts and aggregations. Recorded as C-3 with the rejected alternative and its reason. - Best-effort conversion must cover the float path: the failing branch matches float% and integer%, and NumberUtil has no toFloat/toDouble. - Mapping is long for integer% but double for float% - corrected throughout. - "byte-identical" -> "identical emitted map entries", with the Integer 54 vs Long 54 class-identity trap called out (AC-009). - New AC-010: the happy path must emit no WARN at all. - Disambiguated the Data-considerations sentence that could be read as either "must not write a non-numeric string to the numeric key" or "must not write the numeric key at all". Spec only - no implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…entlet unindexable (#37272) ESMappingAPIImpl.loadFields picks its serialization branch from the storage column (field_contentlet), not the declared field type. dotCMS allows a TextField to be backed by an integerN/floatN column and its own built-in types do it (htmlpageasset.sortOrder is an ImmutableTextField with DataTypes.INTEGER), so the value in hand may be a String. Handing it to DecimalFormat.format() threw IllegalArgumentException, and since the per-field catch rethrows, the whole contentlet was lost from the index — from both engines at once, in every migration phase, because the document is rendered once before the provider fan-out. loadNumericField now handles the branch. A value that is already a Number takes the identical pre-existing path — same object, same formatter call — so no document for correctly-stored content moves. A String is converted best-effort against the column's own type via the new NumberUtil.toLongOrEmpty / toFloatOrEmpty, which report failure instead of substituting a default. When the value cannot be represented numerically, BOTH keys are omitted. That pairing is forced, not stylistic: toMap derives <field>_dotraw from <field>_text and falls back to the bare key when it is absent, so writing the numeric key alone yields an UNPADDED _dotraw. Since _dotraw is a keyword and every sort resolves through it (addBuilderSort appends the suffix to whatever field name the caller passes), fixed-width zero padding is the only reason ordering by a numeric field is numeric rather than alphabetical. An unpadded entry silently reorders results. The numeric key is omitted rather than defaulted to 0 because a 0 is indistinguishable from a genuine 0 in range queries, sorts and aggregations, with only a WARN to reveal the difference. Deliberately out of scope, now tracked in #37462: the surrounding per-field catch still rethrows, so any other unserializable field still costs the whole document. Changing that alters how indexing fails in the hottest path in indexing, and forcing an unexpected serialization failure has no test seam — that decision belongs in its own cycle. Tests: 26/26 unit over the converters (signs, whitespace, already-a-Number, blanks, overflow, fractional-in-integer, and the non-finite NaN/Infinity that Float.parseFloat accepts but JSON cannot carry); 15/15 integration over the emission contract via toMap, including the _dotraw invariant, the omit-both contract, log level and silence on the happy path, the neighbouring branches of the edited else-if chain, and end-to-end proof through a real index that convertible values sort numerically and that an unconvertible document does not answer a range query. Registered in MainSuite1b and phase-agnostic, so the weekly OpenSearch Phase Sweep runs it without reproducing #37432. Also documents the _dotraw padding invariant in docs/backend, since the chain from loadFields to addBuilderSort is invisible from any single file and has already been broken once during review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @fabrizzio-dotCMS's task in 2m 33s —— View job PR Review
I focused review where you asked — the both-keys-together pairing and the omit-vs- FindingsNo blocking issues found. The fix is correct and the design decisions hold up under scrutiny:
Minor notes (non-blocking, no action required)
The scope boundaries (rethrowing catch left to #37462, Nice work — the tests are unusually good at targeting the exact regressions the design guards against. |
|
Approved ✅ The fix correctly isolates the Consistent with the existing branch structure in Test coverage is thorough: unit + integration tests covered. |
Closes #37272. PR 2 of 2 in the Spec-Kit flow — the implementation. The spec is PR #37393 (approved by @dario-daza); until it merges this PR's diff also shows the spec commit, which is the shared ancestor rather than a duplicate.
The defect
ESMappingAPIImpl.loadFieldspicks its serialization branch from the storage column (field_contentlet), not the declared field type. dotCMS allows aTextFieldto be backed by anintegerN/floatNcolumn, and its own built-in types do it —htmlpageasset.sortOrderis anImmutableTextFieldwithDataTypes.INTEGER. So the value in hand may be aString, and handing it toDecimalFormat.format()threw:Because the per-field catch rethrows, the whole contentlet was lost from the index — every other field included. And from both engines at once, in every migration phase: the document is rendered once before the provider fan-out (
ContentletIndexAPIImpl.mapContentletForProcessor— "Compute mapping once; reuse across all providers").The fix
loadNumericFieldnow owns that branch:NumberStringthat converts ("54","54.3")Conversion is
NumberUtil.toLongOrEmpty/toFloatOrEmpty— new, and they report failure instead of substituting a default, because the caller must be able to omit rather than fabricate.Routing a
Numberstraight through is deliberate: it makes "no correctly-stored document moves" true by construction rather than dependent on the converter being equivalent.Why both keys must go together
This is the part worth reviewing closely — it is not stylistic.
toMapderives<field>_dotrawfrom<field>_text, and falls back to the bare key when_textis absent, unpadded. So writing the numeric key alone produces a malformed_dotraw. And_dotrawis akeywordthat every sort resolves through —addBuilderSortappends the suffix to whatever field name the caller passes. Fixed-width zero padding is therefore the only reason ordering by a numeric field is numeric rather than alphabetical:"N/A"sorts after every padded digit string ('N'= 0x4E >'9'= 0x39)54sorts before9Either way a listing silently reorders, with no error anywhere. The chain runs from
loadFieldsthroughtoMap's post-processing toaddBuilderSortand is invisible from any single file, so it is now written down indocs/backend/INDEX_FIELD_EMISSION.md— it had already been broken once, in the spec's own first revision.Omit, not zero
The numeric key is skipped rather than defaulted to
0: a0is indistinguishable from a genuine0in range queries, sorts and aggregations, with only a WARN to reveal the difference. A false in-range match is a correctness bug; an unset field is an honest absence. Recorded as decision C-3 in the spec, with the rejected alternative.Tests
26/26 unit over the converters — signs, surrounding whitespace, already-a-
Number, blanks, overflow, fractional-in-integer (rejected, not truncated), and the non-finiteNaN/InfinitythatFloat.parseFloataccepts but JSON cannot carry.15/15 integration over the emission contract via
toMap:_dotrawinvariant — asserted as absence of the key, sinceassertNotEquals(rawText, …)would pass against the unpadded-number bugInteger 54is notequalstoLong 54)else ifchain — boolean, date, general text[0 TO 10]Registered in
MainSuite1band deliberately phase-agnostic — it exercises document construction only, never callssetPhase(...)and never assumes an index name resolves, so the weekly OpenSearch Phase Sweep runs it without reproducing #37432.Run:
Deliberately out of scope
The surrounding per-field catch still rethrows — any other unserializable field still costs the whole document. Now tracked in #37462. Left out on purpose: it changes how indexing fails in the hottest path in indexing, converting a loud failure (document absent) into a quiet one (document present, one field short), and forcing an unexpected serialization failure has no test seam. That belongs in its own cycle with its own review.
Also out, per the spec's non-goals: the index mapping generator (rollback-unsafe, forces a reindex), repairing existing mis-typed stored values, content-type-save validation (it already permits this modelling, and forbidding it would break dotCMS's own built-in types at bootstrap), and
ImportStarterUtil— the one ingestion path that can create this data, named as a known gap.Rollback safety
Not rollback-unsafe. No mapping, schema, API-contract or serialized-state change. For existing good data the emitted document is unchanged, so old and new pods emit identical documents during a rolling deploy. For the currently-failing contentlets the change is additive — documents that were absent start appearing; a rollback returns them to absent, which is where they are today. No data repair needed: a normal reindex recovers them.
Review focus
The both-keys-together pairing and the omit-vs-
0call. Everything else follows from them.🤖 Generated with Claude Code