ESQL: Union Types Support#107545
Conversation
|
Hi @craigtaverner, I've created a changelog YAML for you. |
d740c88 to
e76988c
Compare
|
Hi @craigtaverner, I've created a changelog YAML for you. |
13c4186 to
20582c4
Compare
There was a problem hiding this comment.
This rule tries to rollback something that either shouldn't have occurred in the first place or that is ignored by the verifier.
If there's no AbstractConvertFunction, MultiTypeFields can considered unsupported in projections and unsupported inside expressions (see EsqlProject and the Verifier).
There was a problem hiding this comment.
This rule does something that used to be done by the ResolveRefs code. We have a catch-22 situation where ResolveUnionTypes depends on ResolveRefs to run first, but also relies on InvalidMappedField (or currently MultiTypeEsField.UnresolvedField) to not be converted into an UnresolvedAttribute (which ResolveRefs also does). So that conversion needed to be removed from ResolveRefs and moved after ResolveUnionTypes. This is that new location. This code also did something much more complex before, also editing the contents of EsRelation and the contained EsIndex, but that was getting very messy, and only needed to handle regression tests around unsupported types. I had three choices:
- Continue to make UnresolveUnionTypes more complex (I got all but one regression test passing, so just needed to add nested resolution of Object fields)
- Find a way to get
MultiTypeEsField.UnresolvedFieldto pass through Plan serialization (this is the route I took, but it looks hacky, and I see your comment on that already). This allowed me to reduce this class a lot (you see the simplified version here). - Merge
MultiTypeEsField.UnresolvedFieldwithInvalidMappedFieldso we avoid the plan serialisation hack. This is my new preferred approach, and I see you suggest this approach too in another comment.
But in none of these cases does this class completely disappear. Somewhere we need to recognise InvalidMappedField as an unresolved type. That used to happen in ResolveRefs, and now happens here. You are suggesting an alternative approach/location? Verifying EsqlProject? I can investigate that on Monday. I've looked through the Verifier a few times, so I can imagine possibilities there.
There was a problem hiding this comment.
Thanks - it's a minor point but you could incorporate the rule as part of the UnionType rules in a second pass.
That is convert all InvalidMappedFields that were are not wrapped in a conversion function to the unresolved attribute.
Again minor.
There was a problem hiding this comment.
This might be causing more problems than it tries to fix. Allow AbstractConvertFunction to work on InvalidMappedField and replace them with MultiType/ConvertedEsField.
There was a problem hiding this comment.
Agreed. It did actually fix a bunch of things, because UnresolveUnionTypes was becoming quite complex in order to get regressions to pass, and putting this here made it much simpler. However, if I instead combine the classes InvalidMappedField and MultiTypeEsField.UnresolvedField into one class, that same simplification occurs, without this hack. I noticed a lot of failures in CI that I could not reproduce locally, but this particular hack feels like a likely source of the failures.
There was a problem hiding this comment.
Also, in case it was not clear, the existence of this in the plan was only when there is no AbstractConvertFunction, so the remaining InvalidMappedField needs to be serialised in the plan, to support existing behaviour around unsupported types.
There was a problem hiding this comment.
Why not use InvalidMappedField directly since that is already picked up by the analyzer/verifier.
There was a problem hiding this comment.
Since InvalidMappedField was in QL, I did not want to touch it, but I'm been coming round more and more to the idea that I really should. I think it will make things much simpler, and avoid the awkward PlanNamedTypes hack.
There was a problem hiding this comment.
This conversation has come full circle now, due to the recent reviews asking to not edit QL directly. After the above comment, I switched to editing QL directly. There were two classes where we edited QL FieldAttribute where the equals and hashcode were updated to include the underlying field, so query plan re-writing would work (could be seen as an oversight in QL code, so perhaps even a bug-fix), and the above mentioned InvalidMappedField. An attempt to completely extricate these two from QL failed (dependencies increased scope dramatically, turning a moderate PR into a monster). A second attempt at a compromise by making ESQL versions that maintained the simple name, and extended the QL versions worked for both classes, but was quite messy for FieldAttribute. After discussing with the team, we decided to keep the ESQL port of InvalidMappedField, but revert to the direct edit of the FieldAttribute. The thinking here is the up-coming split of QL and ESQL would be slightly facilitated by not having any edits in InvalidMappedField, while the edit to FieldAttribute we would probably want to keep anyway.
4125129 to
cc66455
Compare
|
Pinging @elastic/es-analytical-engine (Team:Analytics) |
|
After some recent refinements, and the addition of the unit tests in |
There was a problem hiding this comment.
Hi Craig, I did a couple of tests, but I think I need some guidance to understand which types/functions this PR covers, because I couldn't manage to make it work...
I did the following:
- created two indexes,
test1andtest2with only one@timestampfield of typelonganddaterespectively. - tried a few queries with empty indexes:
from test* | eval x = to_long(@timestamp) | keep x: all goodfrom test* | eval x = to_string(@timestamp) | keep x: all goodfrom test* | eval x = to_date(@timestamp) | keep x:verification_exception,Found 1 problem\nline 1:31: Cannot use field [@timestamp] due to ambiguities being mapped as [2] incompatible types: [date] in [test2], [long] in [test1]
Then I added one record per index
{"index": {"_index":"test1"}}
{"@timestamp":10000000}
{"index": {"_index":"test2"}}
{"@timestamp":"2022-05-06T12:01:00.000Z"}
and tried the same queries again:
from test* | eval x = to_long(@timestamp) | keep x:null_pointer_exception,Cannot invoke \"Object.hashCode()\" because \"pk\" is nullfrom test* | eval x = to_string(@timestamp) | keep x: same as abovefrom test* | eval x = to_date(@timestamp) | keep x:verification_exception.Found 1 problem\nline 1:31: Cannot use field [@timestamp] due to ambiguities being mapped as [2] incompatible types: [date] in [test2], [long] in [test1]from test* | eval x = to_ip(@timestamp) | keep x:esql_illegal_argument_exception,illegal data type [long](suppressednull_pointer_exceptionas above)
I also tried IP vs long and I got some results with to_string() but not with other functions.
So a couple of questions:
- does this cover all the types? I didn't find any type-specific code in the PR, so I assumed it was the case.
- is it supposed to handle partially invalid conversions? Eg. if I have an IP and a date and I'm only interested in the date, can I do it?
[edit] it should be to_datetime() (to_date() does not exist); probably the validation order makes it trip on the incompatible types before it realizes that the function name is wrong, it makes the message a bit confusing, but probably it's a minor problem.
The above queries, with to_datetime() return the same error as to_long()
30047bf to
78824e7
Compare
|
The issues with @timestamp have been fixed in #6fb0622dc43070aa3c71c425d405ac273bf43d45, and more tests added in that and other commits to cover this case. The related issue regarding exactly which error message to return can be dealt with later, perhaps in this PR, or perhaps in another. |
|
I feel bad for the github user |
nik9000
left a comment
There was a problem hiding this comment.
I left a few comments. I suggested reworking how row-by-row loading is modeled with the BlockBuilders inside of the value loaders themselves. This feels like it's much cleaner to think about. I think we can make it compatible with a shared loader if we need to. But for now the copying seems fine because this is never the hottest path.
.../esql/compute/src/main/java/org/elasticsearch/compute/lucene/ValuesSourceReaderOperator.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Could we keep the BlockBuilder and converter inside the block loader itself? That'd rework the row-stride block loader somewhat, but I think that'd make this more readable.
There was a problem hiding this comment.
I remember originally trying the combine these, but seem to remember it causing hassles with the ColumnAtATimeReader. This was before I really got the row-stride reader working, so perhaps it is time for another attempt.
There was a problem hiding this comment.
I'd be ok delaying it to a followup too.
There was a problem hiding this comment.
I realise a key difference between what you suggested and what I tried (and was thinking of trying again). You said to put the builder and converter inside the loader. Instead I had tried to put the converter inside the builder. This is because we will always have both a builder and loader (the builder is made by the loader once additional information is known using code like field.loader.builder(loaderBlockFactory, docs.count())), so merging the loader and builder seems unworkable. Right now the converter is the only thing at play. It is currently implemented by the loader, since it does not need any of the information in the builder. But this does lead to lines like this:
Block build() {
return (Block) loader.convert(builder.build());
}
If I put the converter into the builder, we can hide it inside the build() method call, simplifying things slightly. That was what I was trying before, and could try again.
There was a problem hiding this comment.
I'd like to replace the IT_tests_only suffix with a check against a feature similar to how we do version testing. We're already going to add features for BWC skipping, we can add a skip in the hand-rolled CSV testing infrastructure too. I did it for one example here: #108313
Would you be ok doing the same on this one?
There was a problem hiding this comment.
OK. I've done this now, will push soon.
...in/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java
Outdated
Show resolved
Hide resolved
...in/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Could you include the evaluator too?
...in/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Modifying this one scares me a bit.
There was a problem hiding this comment.
Instead of modifying this, we should create a copy in the esql project.
This may involve some yak shaving, but I'm happy to help with that!
e2c21ff to
13cc584
Compare
costin
left a comment
There was a problem hiding this comment.
I have minor comments around the logistics and code dependency - such as moving the QL classes like FieldAttribute, EsRelation and InvalidMapperField to ESQL.
An alternative would be to have some workaround code in ESQL that adds/maintains that information outside of QL (even though it's ugly) and then get rid of that during the QL migration to not postpone this PR any longer.
...in/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsPhysicalOperationProviders.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Unrelated to this PR - we need to come up with a naming strategy for the features so we can reason about when they were added without having to open this file which is already too long.
I propose YY_MM.esql.feature_name pattern with the date prefix used in the variable name as well:
NodeFeature 24_05_UNION_TYPES = new NodeFeature("24_05.esql.union_types")
There was a problem hiding this comment.
This sounds like a solution to a problem we suspect we might get in future, but do not (yet) suffer from, the issue of the creation of too many NodeFeatures. I think there are many solutions to that problem, like regular removal of older NodeFeature instances and references to them, since they should only really matter for BWC and rollover of recent releases. Or some other solution... let's not make it part of this PR (or any feature PR).
There was a problem hiding this comment.
I'm with @craigtaverner on this. I'd prefer not to adopt a new pattern here. I'm not really sure what the pattern should be either. Or if we need one.
There was a problem hiding this comment.
And add the features here in reverse order, so the latest features are at the top (that's because in time, the early features become wildly spread).
There was a problem hiding this comment.
Maybe just stick this one on top and leave a comment saying add new ones on top.
There was a problem hiding this comment.
This is just for those reading - it's an unordered Set - but we humans do read it in order.
There was a problem hiding this comment.
So far it seems everyone, including me, has be appending to the end. So we're talking about reversing the order of the complete list? I can do that, although I don't fully understand the reason for this. The term 'wildly spread' does not clarify things for me.
There was a problem hiding this comment.
Nit: move these methods at the bottom since their non-essential.
There was a problem hiding this comment.
Clone this to ESQL and add the modifications there.
There was a problem hiding this comment.
OK. I'll discuss this with @alex-spies and do it together with the FieldAttribute changes, since both are really part of the 'split from QL' project.
There was a problem hiding this comment.
Done for InvalidMappedField, but we decided to keep the QL changes in FieldAttribute. Moving that was a much bigger job, and it was argued that these changes are an improvement anyway.
There was a problem hiding this comment.
EsRelation was already ported to esql by Nhat - I think this fix should be applied to esql's copy of that, not here.
There was a problem hiding this comment.
Curiously, in the QL version, it used attrs in the hashCode, but not in the equals method. My change adds it to equals. Nhat's version removed it from hashCode. I'm curious if his change fixed something else, that I will now break.
There was a problem hiding this comment.
Turns out the changes I made were no longer necessary, and only needed for an earlier version of union-types. I kept just one clarifying comment.
b1c6dc7 to
35b0fc9
Compare
costin
left a comment
There was a problem hiding this comment.
I think there's room for improvement however this PR took long enough and github doesn't like this commit history.
I'd wait for Andrei's comments, create a separate issue with feedback, merge this in and address the remaining issues in a separate PR.
Thanks for your patience Craig.
Note that one test, `multiIndexIpStringStatsInline` is muted due to failing with the error:
UnresolvedException: Invalid call to dataType on an unresolved object ?client_ip
astefan
left a comment
There was a problem hiding this comment.
Thank you for adding tests. I like them.
I've left few very minor comments and one that is more serious.
I think there is value in the PR and it should be merged, but it needs follow-ups.
LGTM.
There was a problem hiding this comment.
Leftover or follow up item? If it's the latter it would help having a gh issue created.
There was a problem hiding this comment.
Same here about a followup issue.
There was a problem hiding this comment.
This method and the next 5-6 are borrowed from OperatorTestCase. Can you reuse them instead? (this test class is already big and it would help to have a more manageable size)
There was a problem hiding this comment.
If I understand this well, you remove these attributes assuming they are only used in the "alias function" by which you mean an eval x = field::type. I don't think eval is the only place where you can place such a conversion function.
STATS c=count(*) BY client_ip::ip
FROM sample_data, sample_data_str | SORT client_ip::ip
Both of these have issues. After you merge this PR, I will create follow-ups for these two.
The integration tests do not fail the tests if the capability does not even exist on cluster nodes, instead the tests are ignored. The same behaviour should happen with CsvTests for consistency.
This way we don't have to add more features to the test framework in this PR, but we would probably want a mute feature (like a `skip` line).
Since the sub-fields are AbstractConvertFunction expressions, and Expression is not yet fully supported as a category class for NamedWritable, we need a few slight tweaks to this, notably registering this explicitly in the EsqlPlugin, as well as calling PlanStreamInput.readExpression() instead of StreamInput.readNamedWritable(Expression.class). These can be removed later once Expression is fully supported as a category class.
We used required_capability to mute the tests, but this caused issues with CsvTests which also uses this as a spelling mistake checker for typing the capability name wrong, so we tried to use muted-tests.yml, but that only mutes tests in specific run configurations (ie. we need to mute each and every IT class separately). So now we just remove the tests entirely. We left a comment in the muted-tests.yml file for future reference about how to mute csv-spec tests.
| in.readString(), | ||
| DataType.readFrom(in), | ||
| in.readBoolean(), | ||
| in.readImmutableMap(StreamInput::readString, i -> ((PlanStreamInput) i).readExpression()) |
There was a problem hiding this comment.
I tend to just do readMap( i -> ((PlanStreamInput) i).readExpression())
There was a problem hiding this comment.
That's a little more convenient and I just trust that we don't mutate the map.
Though I should probably do:
readImutableMap(i -> ((PlanStreamInput) i).readExpression())
nik9000
left a comment
There was a problem hiding this comment.
@craigtaverner told me that the concurrent serialization tests are taking a surprising amount of time on this PR so I'm going to check that.
| out.writeString(getName()); | ||
| out.writeString(getDataType().typeName()); | ||
| out.writeBoolean(isAggregatable()); | ||
| out.writeMap(getIndexToConversionExpressions(), (o, v) -> out.writeNamedWriteable(v)); |
There was a problem hiding this comment.
👍
This what we need. It lines up perfectly with writeNamed from the old stuff.
There was a problem hiding this comment.
Actually, I don't believe this is going to work yet. Expression isn't yet fully properly implemented as a NamedWriteable. You'd be better off doing
out.writeMap(getIndexToConversionExpressions(), (o, v) -> ((PlanStreamOutput) o).writeExpression(v))
I'm in the process of converting all remaining Expression subclasses to proper NamedWriteables. they don't all properly implement it yet.
There was a problem hiding this comment.
And yet it does work. At least my multi-node integration tests pass, and they rely on this serialisation succeeding (both for write and read). I assumed it was because the on-the-wire serialization was the same in both cases.
There was a problem hiding this comment.
It'll only work if we've implemented NamedWriteable for all the subclasses you want to serialize. I'm doing that with stuff like #109892 but not all subclasses are done. I suppose if you only allow certain stuff in there it'll be fine. I dunno.
| in.readString(), | ||
| DataType.readFrom(in), | ||
| in.readBoolean(), | ||
| in.readImmutableMap(StreamInput::readString, i -> ((PlanStreamInput) i).readExpression()) |
There was a problem hiding this comment.
That's a little more convenient and I just trust that we don't mutate the map.
Though I should probably do:
readImutableMap(i -> ((PlanStreamInput) i).readExpression())
| } | ||
|
|
||
| public MultiTypeEsField(StreamInput in) throws IOException { | ||
| // TODO: Change the conversion expression serialization to i.readNamedWriteable(Expression.class) once Expression is fully supported |
There was a problem hiding this comment.
That TODO will get done before too long. I'll remove readExpression entirely.
| @@ -0,0 +1,203 @@ | |||
| setup: | |||
There was a problem hiding this comment.
The word "nested" means the nested type to me. Could you rename this file to something like "sub_fields" or something? I forgot what we call those sub field things, but nested and object mean something specific to me.
Recreating the config on every test was very expensive.
60d194c to
bd08a43
Compare
|
@elasticmachine update branch |
bpintea
left a comment
There was a problem hiding this comment.
Leaving some comments that could be considered for later.
| return new Exact(false, "Field [" + getName() + "] is invalid, cannot access it"); | ||
| } | ||
|
|
||
| public Map<String, Set<String>> getTypesToIndices() { |
There was a problem hiding this comment.
Nit: just to preserve the getters style.
| public Map<String, Set<String>> getTypesToIndices() { | |
| public Map<String, Set<String>> typesToIndices() { |
| } | ||
|
|
||
| /** | ||
| * Constructor supporting union types, used in ES|QL. |
There was a problem hiding this comment.
Nit: since it's no longer in QL.
| * Constructor supporting union types, used in ES|QL. | |
| * Constructor supporting union types. |
| /** | ||
| * Representation of field mapped differently across indices. | ||
| * Used during mapping discovery only. | ||
| * Note that the field <code>typesToIndices</code> is not serialized because that information is |
There was a problem hiding this comment.
Wondering if there's an actual valid case when such an object does get serialised (outside tests, that is).
There was a problem hiding this comment.
There really should not be. This class, InvalidMappedField is used in the Analyzer to produce either UnsupportedField, or MultiTypeEsField (the non-union-types and the union-types), long before any serialization. The typesToIndices is used to produce the think that those two fields need, either and error message, or the union-types converters (which are serialized as part of MultiTypeEsField).
What would make sense in future, once the port to NamedWritable is complete, would be for this class to throw an exception!
| return plan; | ||
| } | ||
|
|
||
| private Expression resolveConvertFunction(AbstractConvertFunction convert, List<FieldAttribute> unionFieldAttributes) { |
There was a problem hiding this comment.
All the private methods in this class can be made static.
| return unsupported(name, fc); | ||
| } | ||
| typesToIndices.computeIfAbsent(type.esType(), _key -> new TreeSet<>()).add(ir.getIndexName()); | ||
| typesToIndices.computeIfAbsent(type.typeName(), _key -> new TreeSet<>()).add(ir.getIndexName()); |
There was a problem hiding this comment.
Why not use the type as key? That'll make the repeated type resolution in ResolveUnionTypes#resolveConvertFunction() redundant.
Edit: and #resolvedMultiTypeEsField().
There was a problem hiding this comment.
Yeah, I think I just minimized the differences to the original code, but your idea makes sense.
| TypeResolutionKey key = new TypeResolutionKey(fa.name(), type); | ||
| var concreteConvert = typeSpecificConvert(convert, fa.source(), type, imf); | ||
| typeResolutions.put(key, concreteConvert); | ||
| } |
There was a problem hiding this comment.
Shouldn't the the alternative branch to this if return convert; already? And the check below skipped? If in one index the field is mapped as a type that the conversion function doesn't support, there shouldn't be any MultiTypeEsField created anyways (and the analysis would converge sooner).
| return MultiTypeEsField.resolveFrom(imf, typesToConversionExpressions); | ||
| } | ||
|
|
||
| private Expression typeSpecificConvert(AbstractConvertFunction convert, Source source, DataType type, InvalidMappedField mtf) { |
There was a problem hiding this comment.
| private Expression typeSpecificConvert(AbstractConvertFunction convert, Source source, DataType type, InvalidMappedField mtf) { | |
| private Expression typeSpecificConvert(AbstractConvertFunction convert, Source source, DataType type, InvalidMappedField imf) { |
bpintea
left a comment
There was a problem hiding this comment.
And a last batch of (post-merge) notes to potentially be later considered.
Very nice.
| imf.getTypesToIndices().forEach((typeName, indexNames) -> { | ||
| DataType type = DataType.fromTypeName(typeName); | ||
| TypeResolutionKey key = new TypeResolutionKey(fa.name(), type); | ||
| if (typeResolutions.containsKey(key)) { |
There was a problem hiding this comment.
Can there be a case this check is false?
There was a problem hiding this comment.
Yes, if there are not any convert functions for the types found in the index mappings. Perhaps we have more than one convert function needed, but only one exists in the query. I'm pretty sure I have integration tests (yml tests) that cover this case.
| } | ||
|
|
||
| private MultiTypeEsField resolvedMultiTypeEsField(FieldAttribute fa, HashMap<TypeResolutionKey, Expression> typeResolutions) { | ||
| Map<String, Expression> typesToConversionExpressions = new HashMap<>(); |
There was a problem hiding this comment.
| Map<String, Expression> typesToConversionExpressions = new HashMap<>(); | |
| Map<String, Expression> typesToConversionExpressions = new HashMap<>(typeResolutions.size()); |
|
|
||
| /** | ||
| * During IndexResolution it could occur that the same field is mapped to different types in different indices. | ||
| * The class MultiTypeEfField.UnresolvedField holds that information and allows for later resolution of the field |
There was a problem hiding this comment.
UnresolvedField doesn't exist (anymore?).
There was a problem hiding this comment.
Good point, this comment reflects an earlier version of the class. That is now merged into InvalidMappedField, and not its own class, since we moved QL to ESQL.CORE:
| return ENTRY.name; | ||
| } | ||
|
|
||
| public Map<String, Expression> getIndexToConversionExpressions() { |
There was a problem hiding this comment.
Nit: same style comment as in previous batch about the getter, which we usually have w/o the get prefix when it matches the field name. But not sure if it's that widespread.
| Map<String, Expression> typesToConversionExpressions | ||
| ) { | ||
| Map<String, Set<String>> typesToIndices = invalidMappedField.getTypesToIndices(); | ||
| DataType resolvedDataType = DataType.UNSUPPORTED; |
There was a problem hiding this comment.
Nit:
| DataType resolvedDataType = DataType.UNSUPPORTED; | |
| DataType resolvedDataType = null; |
I guess init'ing to DataType.UNSUPPORTED is safe and maybe caught later down the path (if caused by defect), but it should be an error if resolvedDataType isn't updated.
If the query sources multiple indexes, and the same field exists in multiple indexes with different types, this would normally fail the query. However, if the query includes a conversion function to resolve the field to a single type before it is used in other functions or aggregations, then this should work.
The following query works in this third prototype:
The client_ip field is an
IPin thesample_dataindex, but akeywordin thesample_data_strindex.The first prototype did stuff to the drivers to create an index specific DriverContext to use during field evaluator construction so that the conversion function would be index/type aware. However, that abuses the idea of multi-threaded drivers. So the second prototype took a new approach to instead re-plan the logical plan to extract the converter from the EVAL expressions, setting them as resolved (claiming the input type is already the converted type), and stored the converter in the EsRelation for later use in Physical planning. This third prototype takes this further by replacing the conversion function with a new FieldAttribute. ANd both old and new FieldAttributes exist in parallel, so that the logic around handling unsupported fields is not changed.
Fixes #100603
Tasks to do:
LoadFromManyloadFromSingleLeafDateare not workingKEEPwe get multiple columns with the same name