juliojgd commented on PR #4045:
URL: https://github.com/apache/avro/pull/4045#issuecomment-6194670669

   Thanks @sharanggupta for tackling this. Delegating arrays/maps/unions to the 
model is the right direction, `Utf8.hashCodeFor` looks solid, and the 
description is upfront about what is out of scope. I built the PR head 
(`9b4b14a`) and its base (`28cb08c`) locally and went through it in some depth. 
TL;DR first, details and repros below (line numbers refer to the PR head).
   
   **TL;DR**
   
   - The three scenarios in the JIRA are fixed (plus many sibling shapes), the 
existing suites are green, and the new tests are discriminating (they fail on 
`main`, pass on the PR).
   - **Needs fixing:**
     1. `equals()` now throws `NullPointerException` when an array/map field is 
`null` (behaviour change vs `main`/1.12.1+).
     2. The very same `Unknown datum type` exception (and String/Utf8 
asymmetries) is still reachable through **map values**.
   - **Needs an explicit decision / documentation:**
     3. Widened visibility of `compare(...)` and other new public API, plus 
generated code that no longer runs on an older runtime.
     4. `GenericData.hashCode` changes for maps (this affects `GenericRecord` 
and classes generated by older compilers), and the description is not accurate 
about hash stability in a few cases.
   - **Non-blocking:** (5) cost of `Utf8.hashCodeFor(String)`; (6) `BigDecimal` 
scale vs hash; (7) test coverage and follow-ups (AVRO-4036, opt-out, backport).
   
   ## What I verified
   
   - JDK 21 / Maven 3.9. The CI workflows have not run on this PR yet (only 
`triage`), so these are local results.
   - Your 7 new tests in `TestGeneratedEqualsAndHashCode` **fail on `main`** (5 
failures + 2 errors: `Unknown datum type java.util.UUID` / 
`java.time.LocalDate`, `4066` vs `5058`) and **pass on the PR**.
   - `mvn verify` is green for `avro` (3463 tests), `idl` (39), `compiler` 
(99), `maven-plugin` (15), `ipc` (238) and `integration-test/codegen-test` 
(18). `spotless:check` is clean. I could not run the "Run all tests under Java 
11" invoker IT, and I did not build 
`mapred`/`protobuf`/`thrift`/`tools`/`trevni`/`grpc`; nothing under `lang/java` 
overrides the 4-arg `compare` (see 3a).
   - `Utf8.hashCodeFor` matches `new Utf8(s).hashCode()` (and 
`Arrays.hashCode(s.getBytes(UTF_8))`) on 3M random strings, including unpaired 
surrogates and 1-4 byte characters. No mismatches.
   - I also ran a matrix of ~48 single-field shapes x `stringType` 
`CharSequence`/`String` against 1.12.0, 1.12.2, `main` and the PR. For each 
shape I checked `built.equals(decoded)`, `decoded.equals(built)`, 
`decoded.equals(decoded2)`, hash equality and `HashSet.contains`.
   
   <details>
   <summary>Matrix summary (built = builder/Java types, decoded = after 
encode/decode)</summary>
   
   | Field type (single field of the record) | `main` | PR |
   |---|---|---|
   | JIRA 1: `array<record{["null", uuid]}>` | `decoded.equals(built)` throws | 
OK |
   | JIRA 2: `array<["null", date]>` | throws | OK |
   | JIRA 3: `string` hash, `String` vs `Utf8` | hashes differ (128 vs 159) | 
OK |
   | `["null","string","int"]` holding a `String` | not equal, hashes differ | 
OK |
   | `map<string>`, `array<string>`, `array<map<string>>`, `map<record>`, 
`map<enum>`, `map<["null",date]>`, nested arrays | not equal and/or hashes 
differ | OK |
   | `map<array<["null", uuid]>>`, `map<array<record{["null", uuid]}>>` | 
throws / not equal | **throws (same exception)** |
   | `map<array<string>>` | not equal both ways | **asymmetric** |
   | `map<map<string>>` | not equal both ways | **not equal both ways** |
   | `array<string>` / `map<string>` field `null` vs empty | `false` | **NPE** |
   
   </details>
   
   ---
   
   ## 1. Needs fixing: `equals()` can now throw `NullPointerException`
   
   The generated code calls `MODEL$.compare(this.f, other.f, ...)` with no null 
check (`record.vm:651-652`). `GenericData.compare` dereferences its arguments 
in the ARRAY case (`GenericData.java:1307`, `((Collection) o1).iterator()`) and 
in `compareMaps` (`m1.isEmpty()`). So comparing a record whose array/map field 
is `null` with one where it is not now throws, where `Objects.equals` used to 
return `false`.
   
   The builder rejects nulls for non-nullable fields, but the public no-arg 
constructor, the all-args constructor and the setters do not, so `null` 
array/map fields are easy to hit (mapping frameworks, test fixtures, `new X()` 
plus a few setters).
   
   <details>
   <summary>Repro (using your EqualsOrder)</summary>
   
   ```java
   EqualsOrder unset = new EqualsOrder();                    // array/map 
fields are null
   EqualsOrder defaults = EqualsOrder.newBuilder().build();  // array/map 
fields are empty
   
   unset.equals(defaults);             // main: false      PR: 
NullPointerException ("a1" is null)
   List.of(defaults).contains(unset);  // main: false      PR: 
NullPointerException
   unset.hashCode();                   // fine on both (MODEL$.hashCode(null, 
...) returns 0)
   ```
   
   </details>
   
   1.12.0 (`SpecificRecordBase#equals`) threw the same NPE, so this restores 
the 1.12.0 behaviour. It is still a change for everyone on 1.12.1+, and a nasty 
one: `assertEquals` / `List.contains` blow up instead of failing / returning 
`false`.
   
   **Suggestion:** guard against `null` in the generated code before 
delegating, e.g. `this.f == null ? other.f != null : (other.f == null || 
MODEL$.compare(...) != 0)`, or centralize it in a null-safe helper (see 3b). 
And add a test (null vs empty/default in both directions, plus null vs null):
   
   ```java
   @Test
   void nullCollectionFieldsDoNotThrow() {
     EqualsOrder unset = new EqualsOrder();
     EqualsOrder defaults = EqualsOrder.newBuilder().build();
   
     assertNotEquals(unset, defaults);
     assertNotEquals(defaults, unset);
     assertEquals(unset, new EqualsOrder());
     assertEquals(unset.hashCode(), new EqualsOrder().hashCode());
   }
   ```
   
   ## 2. Needs fixing: the same exception (and asymmetries) is still reachable 
through map values
   
   `GenericData.compare` handles `MAP` by delegating to `compareMaps` 
(`GenericData.java:1318-1320`), which compares the *values* with 
`Object.equals` (`value.equals(value2)`, `GenericData.java:1271`), 
special-casing only `Utf8` vs `String` for scalar values. When a map value is 
an array or a map, none of the machinery this PR relies on (schema, model 
conversions, String/Utf8 equivalence) applies:
   
   - `map<array<["null", uuid]>>` and `map<array<record{["null", uuid]}>>`: 
`decoded.equals(built)` and `decoded.equals(decoded2)` throw the same 
`AvroRuntimeException: Unknown datum type java.util.UUID` (on `main`, 
`decoded.equals(decoded2)` throws as well). Stack trace on the PR head:
   
     ```
     org.apache.avro.AvroRuntimeException: Unknown datum type java.util.UUID: 
de5714a7-0000-0000-0000-000000000001
       at 
org.apache.avro.generic.GenericData.getSchemaName(GenericData.java:979)
       at org.apache.avro.generic.GenericData.resolveUnion(GenericData.java:938)
       at org.apache.avro.generic.GenericData.compare(GenericData.java:1323)
       at org.apache.avro.generic.GenericData.compare(GenericData.java:1313)
       at 
org.apache.avro.generic.GenericData$AbstractArray.equals(GenericData.java:361)  
 <- GenericData.get(): no conversions
       at 
org.apache.avro.generic.GenericData.compareMaps(GenericData.java:1271)          
 <- value.equals(value2)
       at org.apache.avro.generic.GenericData.compare(GenericData.java:1320)
       at org.apache.avro.specific.SpecificData.compare(SpecificData.java:594)
       at ...EqualsOrder.equals(...)
     ```
   
   - `map<array<string>>`: asymmetric. `built.equals(decoded)` is `false` 
(`List<String>.equals(GenericData.Array<Utf8>)`), `decoded.equals(built)` is 
`true`.
   - `map<map<string>>`: `false` in both directions (`Map<String,String>` vs 
`Map<Utf8,Utf8>`).
   
   This is not a new regression (1.12.0 behaves the same and `main` is worse), 
but it is the same root cause as the JIRA, and the new `MAP` case in 
`HashCodeCalculator` is schema-driven while the equality side is not. Since the 
PR already touches `compare` and the MAP hash, I'd fix it here: thread 
`s.getValueType()` from the MAP case into `compareMaps` (keeping the 2-arg 
protected method for subclasses) and compare non-null values with `compare(v1, 
v2, valueSchema, true)`, keeping the existing String/Utf8 key lookup. One side 
effect to be aware of: record values would then be compared field-wise by the 
model rather than through the record's own `equals`, as record elements of 
arrays already are. If you prefer to leave it out of this PR, please open a 
follow-up JIRA and make clear that AVRO-4354 does not remove the exception for 
map values.
   
   Suggested additions to `generated_equals.avsc` (fields of `EqualsOrder`) and 
tests (all three fail on the PR head today):
   
   ```json
   { "name": "idsByKey", "type": { "type": "map", "values": { "type": "array", 
"items": ["null", { "type": "string", "logicalType": "uuid" }] } }, "default": 
{} },
   { "name": "tagsByKey", "type": { "type": "map", "values": { "type": "array", 
"items": "string" } }, "default": {} },
   { "name": "nestedAttributes", "type": { "type": "map", "values": { "type": 
"map", "values": "string" } }, "default": {} }
   ```
   
   ```java
   @Test
   void mapOfArraysOfNullableLogicalType() throws IOException {
     EqualsOrder before = EqualsOrder.newBuilder().setIdsByKey(Map.of("k", 
Arrays.asList(ID, null))).build();
     EqualsOrder after = roundTrip(before);
   
     assertEqualBothWays(before, after);
     assertEqualBothWays(after, roundTrip(before));
   }
   // same shape for tagsByKey (Map.of("k", List.of("a", "b"))) and 
nestedAttributes (Map.of("k", Map.of("x", "y")))
   ```
   
   ## 3. Needs an explicit decision / documentation: new public API and runtime 
coupling
   
   **3a. Widened visibility.** `compare(Object, Object, Schema, boolean)` goes 
from `protected` to `public` in `GenericData` (`:1289`), `SpecificData` 
(`:588`) and `ReflectData` (`:983`). That is source-incompatible for any 
third-party subclass that overrides the protected hook:
   
   ```java
   public class MyData extends GenericData {
     @Override
     protected int compare(Object o1, Object o2, Schema s, boolean equals) {
       return super.compare(o1, o2, s, equals);
     }
   }
   // compiles against main; against the PR: "attempting to assign weaker 
access privileges; was public"
   ```
   
   It stays binary compatible. Nothing under `lang/java` overrides it (I 
grepped all modules) and the build has no japicmp/revapi, so CI would not flag 
it. The PR also adds public `Utf8.hashCodeFor`, 
`SpecificCompiler#requiresModelComparison` and 
`SpecificCompiler#hashCodeFor(Field, String)`, and the now-public `compare` has 
no contract in its Javadoc (null handling, the `equals` flag, exceptions).
   
   **3b. An alternative that widens nothing.** `SpecificRecordBase#equals` 
already calls the protected 4-arg `SpecificData#compare`, which is legal 
because both live in `org.apache.avro.specific` (`SpecificRecordBase.java:79`); 
`SpecificExceptionBase`, the base of generated error types, is in the same 
package. By default generated records/errors extend those bases 
(`SpecificCompiler#getSchemaParentClass`), so the template could call small 
`protected` helpers there (e.g. `fieldEquals(int pos, Object a, Object b)` / 
`fieldHashCode(int pos, Object v)`, names illustrative) that use 
`getSpecificData()` and the schema of field `pos`. That leaves `GenericData` / 
`SpecificData` / `ReflectData` untouched, limits the new API surface to the 
generated-code base classes, shortens the generated code (no 
`SCHEMA$.getFields().get(n).schema()` repeated per field), and is the natural 
place for the null handling from item 1.
   
   **3c. Generated code needs the new runtime.** Code generated by the PR's 
compiler fails at runtime on any runtime that lacks the PR, including a 1.12.2 
runtime:
   
   - record with a `CharSequence` field, `hashCode()`: `NoSuchMethodError: 'int 
org.apache.avro.util.Utf8.hashCodeFor(java.lang.CharSequence)'`
   - record with an array/map/union field, `equals()`: `IllegalAccessError: 
class ...Holder tried to access protected method 'int 
org.apache.avro.specific.SpecificData.compare(...)'`
   
   For comparison, code generated by 1.12.2/`main` runs on a 1.12.0 runtime for 
array/map fields; only `CharSequence` fields need >= 1.12.1 
(`Utf8.compareSequences`, which the description mentions). Old generated code 
on the PR runtime works fine. This matters wherever the Avro runtime is 
dictated by the platform (e.g. Hadoop/Spark/Flink/Kafka classpaths) and the 
plugin version is chosen independently. I'd call it out in the release notes, 
and take it into account if this is backported to `branch-1.12`: a 1.12.3 
plugin would generate classes that do not run on a 1.12.2 runtime within the 
same minor line (plus the API changes above in a patch release).
   
   ## 4. Needs an explicit decision / documentation: runtime hash codes change 
beyond the generated code
   
   The new `MAP` case in `HashCodeCalculator` (`GenericData.java:1176`) changes 
`GenericData.hashCode`, hence `GenericData.Record#hashCode` and 
`SpecificRecordBase#hashCode` of **classes generated by older compilers** (they 
inherit it). Measured with classes generated by 1.12.0 (bytecode untouched, 
only the runtime jar switched from `main` to the PR):
   
   | Record generated by 1.12.0 | `hashCode()` on `main` runtime | on PR 
runtime |
   |---|---|---|
   | `{map<string>}` with `stringType=String` | 89 | 93 |
   | `{map<enum>}` holding a Java enum value | 1270144544 (`Enum.hashCode()` is 
an identity hash, varies per JVM) | 170 (ordinal-based) |
   
   And for a `GenericRecord` `{map<array<string>>}` with `Utf8` keys/values and 
a 12-element array: 1850569164 -> 2061980516 (`Map.hashCode()` used to delegate 
to `List.hashCode()`; the bounded `HashCodeCalculator` now only visits the 
first nine elements). The cases listed in the description (`Utf8`-keyed maps 
with `Utf8`, numeric or record values; arrays of up to nine elements) are 
indeed unchanged, and decoded records keep their 1.12.1/1.12.2 hash, which is 
good.
   
   The new values are arguably more correct (consistent with equality, 
deterministic for enums), but this is a behaviour change for every user of 
`GenericData.hashCode`, not just generated code, in an area where silent hash 
changes have already been reported (AVRO-4334). I'd either split it into its 
own PR/JIRA with its own release note, or list it explicitly in the description 
and release notes.
   
   The description also says that "records generated by this compiler, records 
generated before 1.12.1 and `GenericRecord`s with the same content keep the 
same hash code". That is not accurate in a few cases:
   
   - `HashCodeCalculator` only visits the first nine fields/elements (`counter 
= 10`, `GenericData.java:1146-1205`), whereas the generated `hashCode()` hashes 
every field. For a record with 12 `string` fields the generated `hashCode()` is 
1135094951 and `SpecificData.get().hashCode(record, schema)` is 327880286 
(already the case on `main`/1.12.1+, but 
`hashCodeOfGeneratedRecordMatchesSpecificData` only covers a two-field record, 
which suggests an equivalence that does not hold in general).
   - "Before 1.12.1": `Utf8.hashCode()` itself changed in 1.12.1 (AVRO-4334; 
e.g. `"a"`: 97 -> 128), so the reference point for "unchanged" is 1.12.1+, not 
earlier.
   - The map cases above.
   
   Please reword and, if you keep that test, cover a record with more than nine 
fields.
   
   ## 5. Non-blocking: cost of `Utf8.hashCodeFor(String)`
   
   For a `CharSequence` field holding a `String`, the generated `hashCode()` 
now re-scans the string on every call, where it used to hit the cached 
`String.hashCode()`. Rough numbers (plain timing loop, not JMH; JDK 21, same 
machine; `main` vs PR):
   
   | | `main` | PR |
   |---|---|---|
   | record with one `string` field holding a 200-char `String`: `hashCode()` | 
~7-8 ns | ~240-310 ns |
   | same record decoded (`Utf8`, hash cached): `hashCode()` | ~7 ns | ~7 ns |
   | `map<string>` with 100 `String` entries: `hashCode()` | ~0.22 µs | 
~1.9-2.3 µs |
   | `array<string>` of 1000: `equals()` built / decoded | 4.7-4.9 µs / 9.3-11 
µs | 6.8-7.4 µs / 13-15 µs |
   | `array<string>` of 1000: `hashCode()` built / decoded | ~0.9-1.0 µs / 
~1.1-1.2 µs | ~0.16 µs / ~0.05 µs (only nine elements are hashed) |
   
   This is probably acceptable, but it deserves a sentence in the description: 
code that uses records built from `String`s as hash keys will notice it 
(records decoded from Avro hold `Utf8`, whose hash is cached, and are 
unchanged). If you want to mitigate it: for ASCII-only content the UTF-8 hash 
equals `31^n + s.hashCode()` (n = length), so a fast path on top of the cached 
`String` hash is possible, although it still needs a scan to prove the content 
is ASCII.
   
   ## 6. Non-blocking: `BigDecimal` scale vs hash
   
   For arrays/maps/unions of `decimal`, equality now goes through `compare()`, 
which uses `BigDecimal.compareTo` (scale-insensitive), while the hash uses 
`BigDecimal.hashCode()` (scale-sensitive). So `List.of(new BigDecimal("1.5"))` 
and `List.of(new BigDecimal("1.50"))` in an `array<decimal(9,2)>` field are 
`equals` but have different hash codes (on `main` they were simply not equal). 
It only affects values that were never encoded (the conversion rejects a scale 
different from the schema's on write) and 1.12.0 behaved the same, so low 
priority; but since the JIRA is precisely about the equals/hashCode contract, a 
note or a test would be nice.
   
   ## 7. Tests and follow-ups
   
   Coverage that would have caught the points above:
   
   - the three map shapes (item 2) and null vs empty (item 1);
   - a **non-nullable union** field (e.g. `["null","string","int"]`): 
`requiresModelComparison` has a dedicated `!nullable` branch that no field in 
`generated_equals.avsc` exercises (it works, I checked, but it should be 
covered);
   - `stringType=String` (the `CharSequence` branch of `hashCodeFor` is not 
used there, and arrays/maps hold plain `String`s on both sides);
   - a record with more than nine fields (item 4).
   
   Out of scope here per the description, but worth tracking so that AVRO-4354 
isn't considered fully done:
   
   - `GenericData.AbstractArray#equals` still uses `GenericData.get()` 
(`GenericData.java:361`, AVRO-4036). `record.equals` now works, but comparing 
the extracted list directly still throws: `order.getDates().equals(expected)` 
-> `Unknown datum type java.time.LocalDate` 
(`expected.equals(order.getDates())` is `true`). That is typical in assertions 
like `assertThat(order.getDates()).isEqualTo(expected)`.
   - An opt-out for the generated `equals`/`hashCode` (item 4 of the JIRA). 
Given the behaviour changes above I'd like that escape hatch to be tracked.
   - A backport to `branch-1.12` (the JIRA lists 1.12.1/1.12.2 as affected and 
has no fix version), see 3c.
   - AVRO-4198 is fixed for generated records by this PR, so it's worth 
referencing/closing.
   
   Thanks again, happy to help review follow-ups.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to