perf: Add query splitting by column#388
Conversation
If a query to Snuba involves selecting a large number of columns of raw events, split this into 2 subqueries where: - The first query selects only the project_id, event_id and timestamp - The second query selects all other columns initially requested restricting the query to the set of project_ids, event_ids and timestamp range returned by the first query. This strategy is applied instead of the exponential timesplitting in cases where: - There are no aggregations or groupings - More than the minimal number of columns needed to determine the result set are being selected - Ordering might not be by time This strategy is specific to the Events DaTaSeT. Outlined in https://www.notion.so/sentry/Eventstore-d483b27fefa9463b8723e227d86ca54a
| if is_timestamp_ordered: | ||
| timestamps = [event['timestamp'] for event in result['data']] | ||
| from_date = util.parse_datetime(min(timestamps)) | ||
| to_date = util.parse_datetime(max(timestamps)) |
There was a problem hiding this comment.
This test is failing because an event with the same timestamp as the to_date is not returned
|
@tkaemming since I believe you are making changes to api.py |
tkaemming
left a comment
There was a problem hiding this comment.
Mostly small stuff, overall this looks pretty good.
| min_col_count = len(util.all_referenced_columns(min_col_query)) | ||
|
|
||
| if ( | ||
| use_split and limit |
There was a problem hiding this comment.
There are a few conditions that are shared here and need to be True for us to even consider splitting. I think it'd be clearer if we checked those first (specifically use_split, limit and body.get('groupby') and early returned if any type of split can't/shouldn't be performed, or restructure this like:
if common conditions:
if column conditions:
return col_split(…)
elif time conditions:
return time_split(…)
return query_func(…)It's also cheaper to check the time conditions than the column conditions (since time conditions don't require building a synthetic query) so I'd consider switching the order of evaluation.
There was a problem hiding this comment.
Added the common conditions. Talked to JT about preferring col_split over time_split if both sets of conditions are met though, hence the ordering.
There was a problem hiding this comment.
Oh, right — good point. Might be worth a comment in the code about why the ordering is significant since it's not immediately obvious.
| avoid querying the entire range. | ||
| """ | ||
| def wrapper(*args, **kwargs): | ||
| body = args[0] |
There was a problem hiding this comment.
Not in scope for this change, but we should absolutely not do this this way.
|
|
||
| conditions = body.get('conditions', []) | ||
|
|
||
| if result: |
There was a problem hiding this comment.
What's this conditional for? Should this be result['data']?
The result is always going to contain a bunch of extra stuff in addition to the result rows, so this will always be True, since parse_and_run_query (which is the only really valid de facto value of query_func) will return the value returned by raw_query:
Lines 328 to 330 in cf3e0ff
Lines 366 to 536 in cf3e0ff
There was a problem hiding this comment.
Yeah, result['data'] is what it's supposed to be. If there are no results we just go ahead and do the original query.
|
|
||
| if result: | ||
| project_ids = list(set([event['project_id'] for event in result['data']])) | ||
| conditions.append(('project_id', 'IN', project_ids)) |
There was a problem hiding this comment.
I think this is going to result in multiple project_id IN … in the WHERE clause because of this:
Lines 197 to 199 in cf3e0ff
It probably should just replace the body['project'] value to avoid the unnecessary extra expression in the condition set.
| if is_timestamp_ordered: | ||
| timestamps = [event['timestamp'] for event in result['data']] | ||
| from_date = util.parse_datetime(min(timestamps)) | ||
| to_date = util.parse_datetime(max(timestamps)) + timedelta(seconds=1) |
There was a problem hiding this comment.
It probably makes sense to at least comment here why this + timedelta(seconds=1) works for events. We should later consider adding something like {from,to}_date_inclusive parameters to change the behavior here, since this isn't going to work if anywhere we'd deal with higher precision timestamps.
There was a problem hiding this comment.
Will add a comment. I think it's worth just changing behavior even since Sentry will also have to implement this workaround in places
| event_ids = list(set([event['event_id'] for event in result['data']])) | ||
| conditions.append(('event_id', 'IN', event_ids)) | ||
|
|
||
| if is_timestamp_ordered: |
There was a problem hiding this comment.
I don't know why I added this, it probably doesn't matter how it's ordered we can always shrink the range we're searching
|
@tkaemming Think I've addressed your comments now, can you take another look? |
tkaemming
left a comment
There was a problem hiding this comment.
Some minor notes inline, everything looks good functionally.
Personally, I try and minimize single-use variables (especially if they are defined far away from where they are used) because they make refactoring more challenging since you have to track down all of the places where the variable is actually referenced or mutated. If they are named just for readability reasons, I think that you can generally replace the local definition with a comment that works just as well. (Obviously this is a pretty subjective thing, you're welcome to disagree, everything works as-is.)
|
|
||
| if common_conditions: | ||
| min_col_count = len(util.all_referenced_columns( | ||
| {**body, 'selected_columns': ['project_id', 'event_id', 'timestamp']})) |
There was a problem hiding this comment.
Not a big deal, but both total_col_count (line 23) and min_col_count are going to get eagerly evaluated since they are defined as local variables and not just part of the boolean expression that starts on line 32. (Same with remaining_offset on line 20 and orderby on line 21, these local variables may be set and never utilized.)
| """ | ||
| body = args[0] | ||
|
|
||
| min_cols = ['project_id', 'event_id', 'timestamp'] |
There was a problem hiding this comment.
It seems like it would make sense to define this somewhere it can be reused as part of the expression on line 29? (Eventually, I think this will likely be something that's a method off of Dataset for any time series data — not exactly sure how that's going to look yet, though.)
|
|
||
| min_cols = ['project_id', 'event_id', 'timestamp'] | ||
|
|
||
| minimal_query = {**body, **{'selected_columns': min_cols}} |
There was a problem hiding this comment.
The second dict shouldn't be necessary here:
| minimal_query = {**body, **{'selected_columns': min_cols}} | |
| minimal_query = {**body, 'selected_columns': min_cols} |
| body['project_id'] = project_ids | ||
|
|
||
| event_ids = list(set([event['event_id'] for event in result['data']])) | ||
| conditions.append(('event_id', 'IN', event_ids)) |
There was a problem hiding this comment.
I might consider folding the project_ids and event_ids variables into the expressions that use them to make it clearer that they won't be referenced later.
|
|
||
| second_query = {**body, 'conditions': conditions} | ||
|
|
||
| return query_func(second_query, *args[1:], **kwargs) |
There was a problem hiding this comment.
Similarly, consider folding minimal_query and second_query into the expressions they are used in.
If a query to Snuba involves selecting a large number of columns of raw events,
split this into 2 subqueries where:
restricting the query to the set of project_ids, event_ids and timestamp
range returned by the first query.
This strategy is applied instead of the exponential timesplitting in
cases where:
set are being selected
This strategy is specific to the Events DaTaSeT.
Outlined in https://www.notion.so/sentry/Eventstore-d483b27fefa9463b8723e227d86ca54a