-
Notifications
You must be signed in to change notification settings - Fork 454
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[query] Plumb potential tag completion/aggregate through to m3query endpoints #1481
Conversation
@arnikola I have a branch with some of these types defined too. Lemme put it up and we can merge the best features |
src/dbnode/storage/index/types.go
Outdated
@@ -60,6 +61,13 @@ type Query struct { | |||
idx.Query | |||
} | |||
|
|||
type AggregateQueryOptions struct { | |||
QueryOptions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
better to not embed the type here
src/dbnode/storage/index/types.go
Outdated
type AggregateQueryOptions struct { | ||
QueryOptions | ||
|
||
TagNameFilter [][]byte |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would be more flexible to inject a lambda here
src/dbnode/storage/index/types.go
Outdated
QueryOptions | ||
|
||
TagNameFilter [][]byte | ||
AggregateQueryType rpc.AggregateQueryType |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not a fan of using rpc generated types in the exported interface.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, agreed; was just throwing up something quick and dirty
src/dbnode/client/types.go
Outdated
@@ -78,6 +78,10 @@ type Session interface { | |||
// FetchTaggedIDs resolves the provided query to known IDs. | |||
FetchTaggedIDs(namespace ident.ID, q index.Query, opts index.QueryOptions) (iter TaggedIDsIterator, exhaustive bool, err error) | |||
|
|||
// FetchTags resolves the optionally provided query to any known tag matchers. | |||
// NB: this is a provisional function until the `session` contract is fully defined | |||
FetchTags(namespace ident.ID, q index.Query, opts index.AggregateQueryOptions) (tags FetchedTags, exhaustive bool, err error) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would you feel about Aggregate
instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also, re: the return type - would be better to use an Iterator - allows us to avoid a bunch of allocs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good, I like the iterator you added in #1483; re: Aggregate
where do you think we change the context of the function from being a generic aggregation vs a tag completion; would that be entirely in query? Maybe meet halfway with AggregateTags
😛 ? We've already got tag specific stuff in this interface so wouldn't be a huge change, right?
// NB: fine to overwrite any tags which were present in the `noChildren` map | ||
for _, tag := range withChildren.CompletedTags { | ||
for _, value := range tag.Values { | ||
tagMap[string(value)] = true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you just create a set here instead of the true/false thing? I think you can just do something like:
tagMap := make(map[string]struct{}, mapLength)
then just set it to an empty struct when you're looping through.
for _, value := range tag.Values {
tagMap[string(value)] = struct{}{}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, I need to see if the given tag has children here so need the bool
for _, result := range results { | ||
for _, tags := range result { | ||
jw.BeginObject() | ||
for _, tag := range tags.Tags.Tags { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tags.Tags.Tags
tags on tags on tags - can we possibly come up with a better naming convention?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it's also a list of tags ;)
Agree we could do with a refactor but out of scope here
} | ||
jw.EndObject() | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove newline
// NB: Filter will always be the second last term in the matchers, and the | ||
// matchers should always have a length of at least 2 (term + terminator) | ||
// so this is a sanity check and unexpected in actual execution. | ||
filter := [][]byte{matchers[len(matchers)-2].Name} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should you check to make sure len(matchers) > 2
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
:| yeah so wrote the comment intending to do the check but ended up forgetting to haha
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Coolio
*xhttp.ParseError, | ||
) { | ||
values := r.URL.Query() | ||
now := time.Now() | ||
fromString, untilString := r.FormValue("from"), r.FormValue("until") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are we getting rid of the from and until? I thought the new index supports that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good one, for some reason decided these are always from time.Time{}
until time.Now()
, rather than by default (I think because we drop em later which was wrong too)
func RenderSeriesMatchResultsJSON( | ||
w io.Writer, | ||
results []*storage.CompleteTagsResult, | ||
results []models.Metrics, | ||
dropRole bool, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where is this coming from? Config?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just auto-enabled for all prom requests, and auto-disabled for graphite.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay cool - maybe add a todo to config it. But at this point maybe removing some config stuff is the way to go..
if renderErr := prometheus.RenderSeriesMatchResultsJSON(w, results); renderErr != nil { | ||
logger.Error("unable to write matched series", zap.Error(renderErr)) | ||
xhttp.Error(w, renderErr, http.StatusBadRequest) | ||
if err := prometheus.RenderSeriesMatchResultsJSON(w, results, true); err != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should drop any tags from the results unless that's considered default for Prom. Especially if there's no way for a user to control this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, reluctantly agree... It's a weird tag that's tacked on by prom but will keep it around for the time being
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
src/query/storage/index.go
Outdated
// If no matchers provided, explicitly set this to an AllQuery | ||
if len(matchers) == 0 { | ||
return index.Query{ | ||
// TODO: change this to an idx.AllQuery: https://github.com/m3db/m3/pull/1478 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This returns every id if no matchers are provided? That seems not safe.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's the expected behaviour here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To expound:
If not providing any queries, we want to return the set of all possible tags, and all values that each of those tags can have. We'll be building functionality for this in the index which should actually end up more performant than the new tag completion endpoint (perhaps counterintuitively, but we can get huge gains here when we're not required to filter down to satisfy an arbitrary query)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense
98c0729
to
0c605e6
Compare
@@ -49,46 +57,64 @@ func parseFindParamsToQuery(r *http.Request) ( | |||
untilString = "now" | |||
} | |||
|
|||
from, err := graphite.ParseTime( | |||
end, err := graphite.ParseTime( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this be start? And the other be end?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: maybe just leave it as from
and until
to be consistent with the URL parameter names.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch; added it to the test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
src/query/storage/m3/storage.go
Outdated
return nil, errNoNamespacesConfigured | ||
} | ||
|
||
// TODO: (arnikola): implement a better lifecycle for incoming aggregated tag |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do you want to do this in this PR?
super nit: TODO(arnikola):
instead of TODO: (arnikola)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there's some low hanging fruit gains for query in general, but probably best not to add them here and risk weird pooling issues for now
var mu sync.Mutex | ||
aggIterators := make([]client.AggregatedTagsIterator, 0, initSize) | ||
defer func() { | ||
for _, it := range aggIterators { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
don't you need to lock here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do indeed 👍
src/query/storage/m3/storage.go
Outdated
name, values := aggTagIter.Current() | ||
tagValues := make([][]byte, values.Remaining()) | ||
for j := 0; values.Next(); j++ { | ||
// TODO: (arnikola) fix the lifecycle here so we don't have to clone |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this feels like a redundant copy. you're making a local copy into storage.CompletedTag
and then convert to models.Metrics
above. can you do both in a single pass?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, looking at the tag aggregator again looks like we cast to string, so the copy is unnecessary. Likewise the lifecycle is a bit less janky then I was expecting; there's definitely work to do on pooling/lifecycle management in m3query but that's pretty universally true rather than just in this case
src/query/storage/index.go
Outdated
// If no matchers provided, explicitly set this to an AllQuery | ||
if len(matchers) == 0 { | ||
return index.Query{ | ||
// TODO: change this to an idx.AllQuery: https://github.com/m3db/m3/pull/1478 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there something left server side before we can do this here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, wrote this before AllQuery was pushed and forgot to update it 👍
"github.com/m3db/m3/src/query/storage" | ||
"github.com/m3db/m3/src/query/util/json" | ||
"github.com/m3db/m3/src/x/net/http" | ||
) | ||
|
||
func parseFindParamsToQuery(r *http.Request) ( | ||
*storage.FetchQuery, | ||
func parseFindParamsToQueries(r *http.Request) ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: could you add a comment w/ an example to explain expected input/output
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice, i like it.
if rErr != nil { | ||
xhttp.Error(w, rErr.Inner(), rErr.Code()) | ||
return | ||
} | ||
|
||
opts := storage.NewFetchOptions() | ||
result, err := h.storage.FetchTags(ctx, query, opts) | ||
noChildrenResult, err := h.storage.CompleteTags(ctx, noChildrenQuery, opts) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sorry, no context - what's the difference between noChildrenResult
and childrenResult
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably easiest with an example:
Query is: query=foo.*
This will actually call two Aggregate queries on the session:
foo.*.*
, which is withChildrenResult
, and is translated(-ish) to:
[ { __g0__ =~ foo } , { __g1__ =~ .* }, { __g2__ =~ .* } ]
foo.*
, which is noChildrenResult
, and is translated(-ish) to:
[ { __g0__ =~ foo } , { __g1__ =~ .* }, { __g2__ !=~ .* } ]
,
Where =~
is the regex operator, and !=~
is the regex exclusion operator.
Then these two are combined to determine which fields have children, and which are leaf nodes.
Hopefully this makes sense, will add a comment to this effect
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hm using your example of query=foo.*
, why not just translate into a single aggregate query as follows: [ { __g0__ =~ foo } , { __g1__ =~ .* }, { __g2__ =~ .* } ]
it's exactly the same as the union currently done in a single pass afaict
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
actually, do you even need to use a regex operator for __g0__
? that can be a straight up term operator
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea; added an exact matcher case for alphanumeric segments but will look into properly detecting regex-able statements
Codecov Report
@@ Coverage Diff @@
## master #1481 +/- ##
=========================================
+ Coverage 51.4% 71% +19.6%
=========================================
Files 307 852 +545
Lines 26019 72877 +46858
=========================================
+ Hits 13394 51815 +38421
- Misses 11669 17665 +5996
- Partials 956 3397 +2441
Continue to review full report at Codecov.
|
var err error | ||
terminatedResult, err = h.storage.CompleteTags(ctx, terminatedQuery, opts) | ||
if err != nil { | ||
multiErr = multiErr.Add(err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Need to do locking on this multiErr
.
Instead perhaps just keep both separate then use a xerrors.FirstError(terminatedErr, childErr)
instead of multiErr.FinalError()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah my bad, we have another multiErr type somewhere which is locking; I like the FirstError suggestion more anyway 👍
) | ||
|
||
// NB: small optimization to use exact matcher rather than regex if only | ||
// alphanumeric characters present. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't it make more sense to do always try to do a glob and make glob return ([]byte, bool)
and if it returns false
to imply that it didn't need to turn the glob into a regexp then you can use a MatchEqual matcher?
I'm pretty sure that's how we used to do it (and helps in the case you use a non alphanumeric symbol but turns out that it's still not a regexp? (i.e. using the -
or _
character).
src/query/storage/index.go
Outdated
return index.Query{Query: val}, nil | ||
} | ||
|
||
cache.RUnlock() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you simplify the above to not have to do two RUnlocks?
k := queryKey(matchers)
cache.RLock()
val, ok := cache.get(k)
cache.RUnlock()
if ok {
return index.Query{Query: val}, nil
}
//....
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, looks better
src/query/storage/m3/storage.go
Outdated
} | ||
|
||
var mu sync.Mutex | ||
aggIterators := make([]client.AggregatedTagsIterator, 0, initSize) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why don't you make this len(namespaces)
since you get back one for each namespace?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, good call
defer func() { | ||
mu.Lock() | ||
for _, it := range aggIterators { | ||
it.Finalize() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will this actually finalize all bytes from all the results that came back? If so, we need to copy them all below (right now we're just taking ref's to the bytes).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I initially was cloning the returned bytes, but if you look into how completed tags builder works, it should already copy the bytes when taking a string
It's a bit janky but should hold up
name, values := aggTagIter.Current() | ||
tagValues := make([][]byte, values.Remaining()) | ||
for j := 0; values.Next(); j++ { | ||
tagValues[j] = values.Current().Bytes() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this needs to be a copy of the bytes if we're finalizing these results no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same deal here where the accumulator is casting to strings; I'll put up a test to double check though
if !found { | ||
continue | ||
completedTags[i] = storage.CompletedTag{ | ||
Name: name.Bytes(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this needs to be a copy of the bytes if we're finalizing these results no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same deal
terminatedResult *storage.CompleteTagsResult | ||
tErr error | ||
childResult *storage.CompleteTagsResult | ||
cErr error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: You could make this (to slightly compact):
var (
terminateResult, childResult *storage.CompleteTagsResult
termErr, childErr error
// ...
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually not a fan of doing it like that, looks messier to me :(
If it's a standard we're using will update though 👍
} | ||
|
||
mapLength := len(terminatedResult.CompletedTags) + len(childResult.CompletedTags) | ||
tagMap := make(map[string]bool, mapLength) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: In future we should use an autogen'd map that can use []byte
as the key and we could then use SetUnsafe(key, ..., Options{NoCopyKey: true, NoFinalizeKey: true})
.
8f97936
to
65a5523
Compare
65a5523
to
74a34b4
Compare
ctx context.Context, | ||
query *storage.FetchQuery, | ||
_ *storage.FetchOptions, | ||
) (*storage.SearchResults, error) { | ||
return nil, errors.New("FetchTags not implemented") | ||
return nil, errors.New("SearchSeries not implemented") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what's the deal with this storage type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a weird handrolled mock; we should probably strip it and get a real mock in instead
func parseFindParamsToQueries(r *http.Request) ( | ||
*storage.CompleteTagsQuery, | ||
*storage.CompleteTagsQuery, | ||
string, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: what is this string supposed to be?
i've taken to giving returned variables name for this reason in the method spec (but no naked returns in the code itself). i.e. something like:
func parseFindParamsToQueries(r *http.Request) (
terminatedQuery *storage.CompleteTagsQuery,
childQuery *storage.CompleteTagsQuery,
? string,
err *xhttp.ParseError,
) {
values := r.URL.Query()
query := values.Get("query")
if query == "" {
return nil, nil, "", xhttp.NewParseError(errors.ErrNoQueryFound, http.StatusBadRequest)
// notice how this isn't just "return"
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sgtm
func ParseTagCompletionParamsToQuery( | ||
r *http.Request, | ||
) (*storage.CompleteTagsQuery, *xhttp.ParseError) { | ||
tagQuery := storage.CompleteTagsQuery{} | ||
tagQuery := storage.CompleteTagsQuery{ | ||
Start: time.Time{}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just making sure this is the default that can be overriden by the parameters passed over http
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It can not... fixing
@@ -191,7 +196,7 @@ func ParseSeriesMatchQuery( | |||
return nil, xhttp.NewParseError(err, http.StatusBadRequest) | |||
} | |||
|
|||
tagMatchers := make([]models.Matchers, len(matcherValues)) | |||
queries := make([]*storage.FetchQuery, len(matcherValues)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
on line 189 - what's with the magic numbers? (unrelated to this change)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was defaulting to 40 days as query start for ...reasons; changed to go from 0 until Now by default
@@ -52,14 +53,15 @@ const ( | |||
|
|||
var ( | |||
matchValues = []byte(".*") | |||
roleName = []byte("role") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hm this feels like a weird thing to modify a metric with. won't it conflict with people's tags?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, I've disabled stripping it for now
Prom does some dumb thing where it adds a "role":"remote" tag to each metric we get from the remote write endpoint which is questionable, but probably safer to leave it in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, i'd avoid doing anything magicy to the metrics. can add down the road behind config if it's really required
func RenderSeriesMatchResultsJSON( | ||
w io.Writer, | ||
results []*storage.CompleteTagsResult, | ||
results []models.Metrics, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any reason to use custom encoder here? could we get away with just creating the right json object and using the default encoder
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Historical reasons, mostly? We use this hand rolled encoder everywhere at the moment but should definitely look into changing this and using something sane instead
74a34b4
to
dc84075
Compare
dc84075
to
c535a7d
Compare
Adds new aggregate endpoint to Query