Blog

2016.09.14 - 번역 - Instant Aggregations: The Great Query Refactoring: Thou shalt only parse once ...

drscg 2019. 1. 7. 10:28

This is the second post in a three-part series about Instant Aggregations. Read how it all started in The Tale of Caching and Why It Matters from Simon Willnauer and Shay Banon and how the magnificent end to the story in Instant Aggregations: Rewriting Queries for Fun and ProfitEnjoy the trilogy!

이 게시물은 Instant Aggregation에 대한 시리즈 중 두번째 게시물입니다. Simon Willnauer과 Shay Banon의 The Tale of Caching and Why It Matters에서 어떻게 시작되었는지 읽어 보고, Instant Aggregations: Rewriting Queries for Fun and Profit에서 이야기의 결말을 살펴보자.

When writing software, adding cool new features is of course always great fun. But sometimes it’s also important to work on internal changes in the code base that enable those shiny new additions in the future. For example, there were a number of cool new ideas for Elasticsearch floating around that were essentially blocked by the lack of having a good intermediate representation for search requests arriving through the REST layer, which prevented early query optimizations and delayed parsing to the shard level.

SW를 만들 경우, 멋진 새로운 기능을 추가하는 것은 물론 항상 즐겁다. 그러나, 때로는 미래에 빛날 그러한 새로운 추가사항을 가능하게 하는 code base의 내부 변화에 대해 작업하는 것 또한 중요하다. 예를 들어, REST layer를 통해 들어오는 search request에 대한 적절한 중간 표현을 가지지 못하여 본질적으로 차단되는(이것은 초기의 query 최적화를 막았고 shard level의 parse를 지연시켰다) Elasticsearch에 대한 수많은 새로운 idea가 있었다.

For the upcoming release of Elasticsearch 5.0 we  embarked on a large refactoring to change the way search requests work internally. In this blog post we want to highlight some of the changes and challenges that came along with the refactoring of queries and the search request, how it helped us improve our testing and how it enables great new features like "Instant Aggregations", that will be highlighted in a following blog post.

Elasticsearch 5.0 의 출시를 앞두고, 내부적으로 search request가 동작하는 방식을 변경하기 위하여 대규모 refactoring을 시작했다. 이 게시물에서, query와 search request의 refactoring, test 개선 방법, 다음 게시물에 중점적으로 설명할 "Instant Aggregations" 같은 멋진 새로운 기능을 활성화하는 방법과 관련된 몇 가지 변경사항과 문제점을 중점적으로 설명하고자 한다. 

How search requests were sent across the cluster prior to the Refactoring

When you send a search request to a node in the cluster, the node receiving the request coordinates the search request from then on. The coordinating node identifies what shards the search request needs to be executed on, and forwards it to the nodes that hold those shards via the internal node-to-node transport layer. Each shard returns a set of documents, whose ids will be sent back to the coordinating node that is responsible for reducing the matching documents obtained to the top matching ones that need to be returned to the client. Once those documents are identified, they are fetched as part of the fetch phase and finally returned.

cluster의 node로 search request를 보내면, request를 받은 node는 이후 search request를 조정한다. 조정 node는 search request를 실행해야 할 shard를 식별하고, 이를 내부 node간 transport layer를 통해 해당 shard를 가지고 있는 node로 그것을 전달한다. 각 shard는 document의 집합을 return하며, 그것들의 id는 client에게 return해야 하는 획득한 일치하는 document를 상위 일치 document로 줄이는 역활을 하는 조정 node로 다시 보내진다. 일단 이들 document가 식별되면, fetch 단계의 일부로 가져와, 최종적으로 return된다.

Before Elasticsearch 5.0, each node received the original search request, parsed the query and used other information available from the shard (like mappings) to actually create the Lucene query that was then executed as part of the query phase.

Elasticsearch 5.0 이전에서는, 각 node는 원래의 search request를 받아, query를 parse하고, shard에서 이용할 수 있는 다른 정보(mapping 같은)를 이용하여, query 단계의 일부로 실행된 Lucene query를 실제로 생성한다.

parse 1

The body of the search request is not parsed on the coordinating node but rather serialized via the transport layer untouched, as an opaque byte array which holds nothing more than its json representation. This was historically done to avoid having to write serialization code for every single query and every single section of a search request, as elasticsearch uses its own binary serialization protocol. We eventually came to the conclusion that this was the wrong trade-off.

search request의 body는 조정 node에서 parse되지 않고, 그것의 json 표현에 지나지 않는 이해하기 힘든 byte array로써, 변경되지 않은 채로 transport layer를 통해 직렬화된다. 원래부터 Elasticsearch에서는 자체 이진 직렬화 protocol을 사용하기 때문에, 이것은 모든 단일 query와 search request의 모든 단일 section에 대해 직렬화 code를 사용할 필요가 없도록 만들어졌다. 결국 이것이 잘못된 절충(trade-off)라는 결론을 내렸다.

So what's the problem with this?

While simply forwarding incoming requests on the coordinating node as described above is simple and fast (at least on the coordinating node), there are some drawbacks:

위에서 이야기한 것처럼, 조정 node에 들어오는 request를 단순하게 전달하는 것은 간단하고 빠르지만(적어도 조정 node에서는), 몇 가지 단점이 있다.

  • Each search request gets parsed multiple times, once for each shard of the target index. That means that even if with the default of 5 shards, a query will be parsed 5 times across the cluster (even multiple times in the same node if that node holds more than one shard that is relevant for the query). This is a potential waste of cpu-cycles as the search request could be parsed earlier and only once.
    각 search request는 대상 index의 각 shard에서 한번씩, 여러번 parse한다. 즉, 기본값이 5개의 shard를 가지고 있다면, query는 전체 cluster에서 5번 parse될 것이다 (node가 query와 관련된 하나 이상의 shard를 가지고 있는 경우, 동일한 node애서 여러 번). 이는 search request가 더 일찍, 한 번만 parse될 수 있기 때문에, CPU 잠재적인 낭비이다.
  • For the same reason, if a query is malformed, it will be rejected with an error by each shard. This is why in Elasticsearch 1.x you get multiple parse failures in your response when you make a mistake in your query. It is all the same error, but it gets thrown by each shard. On 2.x we already deduplicate those kind of errors, but they are still thrown from all shards.
    동일한 이유로, query의 형식이 잘못되면, 각 shard별로 오류가 발생하여 거부된다. 이 때문에, Elasticsearch 1.x 에서 query를 실수하면, response에서 여러 개의 parse 오류가 발생한다. 그것은 모두 동일한 오류이지만, 각 shard에서 발생한다. 2.x 에서는, 이런 종류의 오류를 중복을 제거했지만, 여전히 모든 shard에서 발생한다.
  • The search request cannot be rewritten for optimizations on the coordinating node without impacting performance, as this would mean having to parse it twice.
    성능에 영향을 미치지 않으면서, 조정 node에서 최적화를 위해 search request를 다시 작성할 수 없다. 이것은 query를 2번 parse한다는 의미이기 때문이다.
  • Parsing and query creation are tightly coupled, making unit testing of each individual step difficult without having the whole context of a shard.
    parse와 query 생성은 밀접하게 관련되어 있어, shard의 전체 context 없이 각각의 개별 단계에 대한 unit test를 어렵게 한다.

All these drawbacks arise because before 5.0 there is no intermediate representation of a query within elasticsearch, only the raw JSON request and the final Lucene query. The latter can only be created on the shards, because Lucene queries are not serializable and often depend on context information only available on the shard. By the way, this is actually true not only for the "query" part of the search request, but for many other things like aggregations, suggestions etc.. that can be part of a search request.

Elasticsearch 5.0 이전에서는 Elasticsearch 내에서 query의 중간 표현 없이, raw JSON request와 최종적인 Lucene query만 있기 때문에, 이 모든 단점이 발생한다.  Lucene query는 직렬화할 수 없고 shard에서만 사용할 수 있는 context 정보에만 이용할 수 있는 정보에 의존하기 때문에, 후자는 shard에서만 발생할 수 있다. 그런데, 이것은 실제로 search request의 "query" 부분 뿐만 아니라 search request의 일부분이 될 수 있는 aggregation, suggestion 등과 같은 많은 부분에서도 사실이다.

So, wouldn't it be nice to be able to parse queries to an intermediate representation once and early? That way we would be able to eventually rewrite them, and optimize their execution, for example by shortcutting them to queries that are less expensive.

그렇다면, query를 중간 표현으로 좀 더 일찍 한번만 parse할 수 있다면 좋지 않을까? 그렇게 하면, 결국 그것들을 다시 작성할 수 있고, 실행을 최적화할 수 있다. 예를 들자면, 비용이 적게 들어가는 query로 변경하여.

What we did

For the queries we achieved this by splitting the query parsing into two phases:

query의 경우, query parse를 2개의 단계로 나누어 이를 수행했다.

  • Json/Yaml parsing that happens on the coordinating node, which allows us to represent a search request in a serializable intermediate format. This phase is independent from mappings and data present in the shards, to make sure that it can be performed on any node. This happens in a method called “fromXContent()” that every query has and which produces a new intermediate query object (all implementing the QueryBuilder interface).
    조정 node에서 발생하는 Json/Yaml parse로 search request를 직렬화할 수 있는 중간 형식으로 나타낼 수 있다. 이 단계는 모든 node에서 실행할 수 있도록, mapping과 shard의 data와 무관하자. 이것은 “fromXContent()” 라는 method에서 발생하는데, 이것은 모든 query가 가지고 있고, 새로운 중간 query object(QueryBuilder interface를 구현하는 모든 object)를 생성한다.
  • Conversion from elasticsearch serializable queries to lucene queries ready to be executed on the shard. This phase depends on mappings and information present in the shards, so it has to happen separately on each shard to be able to run the query against its data. The method handling this in the QueryBuilder interface is called `toQuery()`.
    Elasticsearch의 직렬화할 수 있는 query에서 Lucene query로의 변경은 shard에서 실행된다. 이 단계는 mapping과 shard의 정보에 따라 다르므로, 각 shard의 data에 대해 query를 실행할 수 있도록 각 shard에서 개별적으로 실행해야 한다. QueryBuilder interface에서 이것을 처리하는 method를 `toQuery()` 라 한다.

parse 2

As a result of the split, the code is better organized as parsing is decoupled from lucene query generation. Having a serializable intermediate format for queries meant that we had to write serialization code for all of the queries and search sections supported in elasticsearch. Also, every query implements `equals()` and `hashCode()`, so that they can be compared with each other for easier caching and to aid their testing.

분할의 결과로 parse가 Lucene query 생성과 분리되므로, code가 훨씬 체계적으로 구성되었다. query에 대해 직렬화할 수 있는 중간 형식을 갖는다는 것은 Elasticsearch에서 지원하는 모든 query와 select section에 대한 직렬화 code를 작성해야 한다는 것을 의미했다. 또한 모든 query는 `equals()` 와 `hashCode()`를 구현하여, 보다 쉽게 cache하고 test를 돕기 위해 서로 비교할 수 있다.

Once we moved parsing to the coordinating node, we could also move some validation to earlier stages of a search request and throw one single error earlier. Validation applies to malformed queries in terms of invalid json, queries that are missing some of their required values, or queries with invalid values provided.

parse를 조정 node로 옮기면, 일부 유효성 검사를 search request의 초기 단계로  옮겨서, 단일 오류를 더 일찍 발생시킬 수 있다. 유효하지 않은 json, 일부 필요한 값이 누락된 query, 유효하지 않은 값이 제공된 query와 관련하여, 유효성 검사는 잘못된 query에 적용된다.

In order to make all those changes in a safe way that doesn’t break existing behavior, we wrote extensive unit tests. For each query, we added randomized tests to verify that it can be properly parsed and serialized, added tests that the resulting lucene query is the expected one and also test explicitly that all of the required values are checked and validated correctly. In order to do so, we created a base test class which provides the code necessary for test setup and has some abstract methods that only need to be filled in for each individual query. This base class is shipped as part of our test framework helping downstream developers of custom queries with testing.

이러한 변화들이 기존의 동작들을 방해하지 않는 안전한 방법으로 적용하기 위하여, 광범위한 unit test를 작성했다. 각 query에 대해, 적절하게 parse되고 직렬화될 수 있도록 확인하는 무작위 test를 추가하고, 기대했던 대로 Lucene query가 생성되는지를 test하고, 또 필요한 모든 값을 확인하고 올바르게 검증하는지를 명시적으로 test했다. 이를 위해, test 설정에 필요한 code를 제공하고, 각 개별 query에 대해서만 입력되어야 하는 몇 가지 추상 method를 가진 base test class를 생성했다. 이 base class는 사용자 지정 query의 downstream 개발자가 test를 할 수 있도록, test framework의 일부로 제공된다.

Most of our query parsing code had low unit test coverage before the refactoring. For example, the `org.elasticsearch.index.query` package that contains all the QueryBuilder and QueryParser classes had only 47% test coverage (actually, with randomized testing we cover more over time, these numbers refer to the coverage in one CI run) before the refactoring, going to above 77% coverage after the refactoring branch was merged.

query parse code의 대부분은 refactoring 이전에 낮은 unit test 적용 범위를 가지고 있었다. 예를 들면, 모든 QueryBuilder와 QueryParser class를 가지고 있는 `org.elasticsearch.index.query` package는 refactoring 전에 47% 의 test 적용 범위(사실, 무작위 test를 통해, 시간이 지남에 따라, 더 많은 것을 다룰 수 있다. 이 숫자는 하나의 CI가 실행될 경우의 적용 범위를 말한다)만을 가졌으나, refactoring branch가 병합된 후에는 77% 를 넘어섰다.

How we did it

This query refactoring was really a long running task that needed a proper feature branch. Initially we thought it could be enough to only refactor the query part of the search request, but in the end we went for refactoring almost all parts of the search request. The branch stayed alive for several months and we took care of merging master in daily (or at least twice a week). In some cases we found bugs that we fixed upstream rather than on the branch, to get them out with regular releases as soon as possible.

이 query refactoring은 실제로 적절한 기능 branch를 필요로 하는 장기 실행 작업이었다. 초기에는 search request의 query 부분만 refactoring하면 충분하리라 생각했었으나, 결국 search request의 거의 모든 부분을 refactoring했다. branch는 몇 달동안 남아 있었고, 매일(적어도 일주일에 두번) master 병합을 처리했다. 어떤 경우 branch가 아닌 upstream에서 수정한 bug를 발견했는데, 가능한 한 빨리 그것들을 정기 release에 포함하려 했다.

As a team working on the feature branch, we decided to essentially replicate the development model of how we work on Elasticsearch master: instead of trying to plow through all changes needed and having an enormous bulk review at the very end, each change was submitted and reviewed as a small, manageable pull request against the branch. Having a closely knit team dedicated to this work, we could agree early on the goals of the changes, coding and architectural standards. This later made finding a reviewer familiar with those goals easy and made review cycles faster.

기능 부분에서 일하는 팀으로서, 우리는 Elasticsearch master에서의 작업 방식에 대한 개발 모델을 근본적으로 복제하기로 했다. 모든 필요한 변경 사항을 처리하고 마지막에 막대한한 대량 검토를 하는 대신, 각 변경사항은 branch에 대한 작고, 관리 가능한 pull request로 submit되고 검토되었다. 이 작업에 전념하는 꼼꼼한 팀이 있었기 때문에, 우리는 변경 사항, coding, architecture 기준의 목표에 조기에 의견 일치를 볼 수 있었다. 이것은 나중에 그러한 목표에 익숙한 검토자를 쉽게 찾을 수 있도록 해 주었고, 검토 주기를 단축할 수 있었다.

Given the amount of code that this refactoring touched, the challenging part was to keep the branch healthy while the task was in progress. We had to sit down and come up with some incremental steps to run tests while the refactoring was still in progress. It was all a matter of introducing the new functionality gradually, while leaving the old infrastructure  alive until there was a complete replacement for it. Initially we introduced the two phase parsing but it all still happened on the data nodes rather than on the coordinating node. When all queries were migrated, we were able to move parsing to the coordinating node and start working on the remaining sections of the search request (e.g. rescore, suggestions, highlighting and so on).

이 refactoring에 영향을 받은 code의 양을 감안하면, 어려웠던 부분은 작업이 진행되는 동안 branch를 잘 유지하는 것이었다. refactoring이 진행되는 동안, test를 진행하기 위한 몇 가지 점진적인 단계를 마련해야 했다. 그것은 새로운 가능 점진적으로 도입하는 동시에 기존 infrastructure를 완전히 대체할 때까지 그것을 그대로 유지해야 하는 문제였다. 초기에 2단계 parse를 도입했으나, 그것은 여전히 조정 node가 아닌 data node에서 발생했다. 모든 query를 마이그레이션했을 때, parse를 조정 node로 옮기고, search request의 나머지 section(rescore, suggestion, highlight 등)에 대한 작업을 시작할 수 있었다. 

Lessons learnt

After several months of wading through code that was originally written by multiple authors and at very different times through the evolution of the code base, we learned a couple of things:

원래 여러 명이 작성한 code를 몇 달동안 살펴본 후, code base의 진화를 통해, 매우 여러 번, 몇 가지를 배웠다.

  • just because you’ve refactored a handful of queries already doesn’t mean you have any idea of what the other about 50 classes look like.
    몇 개의 query를 refactoring했다고 해서, 다른 약 50개의 classe를 알고 있는 것은 아니다.
  • in order to keep your branch healthy, it is vital to use CI to frequently run your test suite and deliver improvements in small incremental steps, even though this means doing more work in total.
    branch를 잘 유지하기 위해, CI를 사용하여, test suit을 자주 실행하고, 작고 점진적인 단계로 개선 사항을 전달하는 것이 중요하다. 비록 이것이 전체적으로 더 많은 작업을 의미할지라도.
  • changing architectural decisions years into development does have a non-negligible cost attached, but it’s the right thing to do, pays off in terms of maintainability and enables new features.
    수년간의 architecture 결정을 개발로 변경하는 것은 무시할 수 없는 비용 부담을 동반하지만, 하는 것이 옳고, 유지보수 측면에서 보상이 되고, 새로운 기능을 가능하게 한다.

Conclusion

All in all, the improvements made with this large refactoring become obvious when we need to go back to the old 2.x branch now and then to backport something from the current main development line. Not only has the parsing infrastructure become much more efficient, but introducing the intermediate query representation and decoupling parsing from query creation has lead to much cleaner code and the new test infrastructure makes debugging of parsing-related problems and writing new test much easier.

결론적으로, 이 대규모 refactoring으로 인한 개선 사항은 지금 기존의 2.x branch로 되돌아가, 현재의 주요 개발 line에서 무엇인가를 가져와야 할 때 명백해진다. parse infrastructure가 훨신 효율적이 될 뿐 아니라 중간 query 표현을 도입하고, query 작성에서 parse를 분리하면서, code가 훨씬 깨끗해지고, 새로운 test infrastructure 덕분에 parse와 관련된 문제의 debug와 새로운 test 작성이 훨신 쉬워졌다.

But the most important thing is, that we are now able to analyze, rewrite and optimize search request on the coordinating node much more easily. Features like “instant aggregations” that were long talked about, but never tackled because of the months of work required, suddenly became possible to implement quickly. In an upcoming blog post we will shed some more light on what this feature is and how it works.

그러나, 가장 중요한 것은 이제 조정 node에서 search request를 훨씬 쉽게 본석하고 재작성하고 최적화할 수 있다는 점이다. 오랫동안 이야기되었으나 필요한 작업 기간으로 인해 해결되지 않은 “instant aggregations” 같은 기능이 갑자기 신속하게 구현 가능해졌다. 이어지는 게시물에서, 이 기능이 무엇이고 어떻게 동작하는지 좀 더 살펴볼 것이다.

원문 : The Great Query Refactoring: Thou shalt only parse once