Skip to content
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

쿼리 복잡도 분석 #13

Merged
merged 21 commits into from
Aug 22, 2024
86 changes: 86 additions & 0 deletions dev-resources/complexity-analysis-error.edn
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{:interfaces
{:Node {:fields {:id {:type (non-null ID)}}}
:Edge {:fields {:cursor {:type (non-null String)}
:node {:type (non-null :Node)}}}
:Connection {:fields {:edges {:type (non-null (list (non-null :Edge)))}
:pageInfo {:type (non-null :PageInfo)}}}
:User {:fields {:id {:type (non-null ID)}
:name {:type (non-null String)}}}}

:objects
{:PageInfo
{:fields {:startCursor {:type (non-null String)}
:endCursor {:type (non-null String)}
:hasNextPage {:type (non-null Boolean)}
:hasPreviousPage {:type (non-null Boolean)}}}

:Product
{:implements [:Node]
:fields {:id {:type (non-null ID)}
:seller {:type (non-null :Seller)}
:reviews
{:type (non-null :ReviewConnection)
:args {:first {:type Int}}
:resolve :resolve-reviews}
:likers
{:type (non-null :UserConnection)
:args {:first {:type Int
:default-value 5}}
:resolve :resolve-likers}}}

:ProductEdge
{:implements [:Edge]
:fields {:cursor {:type (non-null String)}
:node {:type (non-null :Product)}}}

:ProductConnection
{:implements [:Connection]
:fields {:edges {:type (non-null (list (non-null :ProductEdge)))}
:pageInfo {:type (non-null :PageInfo)}}}

:Review
{:implements [:Node]
:fields {:id {:type (non-null ID)}
:author {:type (non-null :User)}
:product {:type (non-null :Product)}}}

:ReviewEdge
{:implements [:Edge]
:fields {:cursor {:type (non-null String)}
:node {:type (non-null :Review)}}}

:ReviewConnection
{:implements [:Connection]
:fields {:edges {:type (non-null (list (non-null :ReviewEdge)))}
:pageInfo {:type (non-null :PageInfo)}}}

:Seller
{:implements [:Node :User]
:fields {:id {:type (non-null ID)}
:name {:type (non-null String)}
:products
{:type (non-null :ProductConnection)
:args {:first {:type Int}}
:resolve :resolve-products}}}

:Buyer
{:implements [:Node :User]
:fields {:id {:type (non-null ID)}
:name {:type (non-null String)}
:followings
{:type (non-null :UserConnection)
:args {:first {:type Int}}
:resolve :resolve-followings}}}

:UserEdge
{:fields {:cursor {:type (non-null String)}
:node {:type (non-null :User)}}}

:UserConnection
{:fields {:edges {:type (non-null (list (non-null :UserEdge)))}
:pageInfo {:type (non-null :PageInfo)}}}}

:queries {:node
{:type :Node
:args {:id {:type (non-null ID)}}
:resolve :resolve-node}}}
27 changes: 18 additions & 9 deletions src/com/walmartlabs/lacinia.clj
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
[com.walmartlabs.lacinia.internal-utils :refer [cond-let]]
[com.walmartlabs.lacinia.util :refer [as-error-map]]
[com.walmartlabs.lacinia.resolve :as resolve]
[com.walmartlabs.lacinia.tracing :as tracing])
[com.walmartlabs.lacinia.tracing :as tracing]
[com.walmartlabs.lacinia.complexity-analysis :as complexity-analysis])
(:import (clojure.lang ExceptionInfo)))

(defn ^:private as-errors
Expand All @@ -33,11 +34,13 @@

Returns a [[ResolverResult]] that will deliver the result map, or an exception."
{:added "0.16.0"}
[parsed-query variables context]
{:pre [(map? parsed-query)
(or (nil? context)
(map? context))]}
(cond-let
([parsed-query variables context]
(execute-parsed-query-async parsed-query variables context nil))
([parsed-query variables context options]
{:pre [(map? parsed-query)
(or (nil? context)
(map? context))]}
(cond-let
:let [{:keys [::tracing/timing-start]} parsed-query
;; Validation phase encompasses preparing with query variables and actual validation.
;; It's somewhat all mixed together.
Expand All @@ -55,11 +58,17 @@

(seq validation-errors)
(resolve/resolve-as {:errors validation-errors})

:let [complexity-error (when (:max-complexity options)
(complexity-analysis/complexity-analysis prepared options))]

(some? complexity-error)
(resolve/resolve-as {:errors complexity-error})

:else
(executor/execute-query (assoc context constants/parsed-query-key prepared
::tracing/validation {:start-offset start-offset
:duration (tracing/duration start-nanos)}))))
::tracing/validation {:start-offset start-offset
:duration (tracing/duration start-nanos)})))))

(defn execute-parsed-query
"Prepares a query, by applying query variables to it, resulting in a prepared
Expand All @@ -76,7 +85,7 @@
{:keys [timeout-ms timeout-error]
:or {timeout-ms 0
timeout-error {:message "Query execution timed out."}}} options
execution-result (execute-parsed-query-async parsed-query variables context)
execution-result (execute-parsed-query-async parsed-query variables context options)
Copy link
Author

@1e16miin 1e16miin Jul 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재는 여기서 options에 :max-complexity 라는 key에 value를 넣어서 최대 리소스를 제한하는 것을 고려하였는데요.
system.edn 의 config 에 max-complexity를 하나 정의하여 context로 부터 가져와도 될 것 같다고 생각하는데 고민이긴 합니다.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 백엔드 코드에서 build-lacinia-options할 때 현재 환경이 dev / staging인 경우에만 :max-complexity 값을 하드코딩해서 넘겨도 충분할 것 같습니다(ex: :max-complexity 1000000)
굳이 system.edn에는 추가 안 해도 될 것 같슴다
https://github.com/green-labs/farmmorning-backend/blob/68978493665d1027f45c4a64100a0855d5cb7781/bases/core-api/src/farmmorning/core_api/graphql/handler.clj#L97

다른 lacinia 옵션값들도 그냥 하드코딩하고 있음

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

근데 지금 바로 반영된다면, 앱 코드들에 first: 9999 이런 잔재 때문에 프론트 개발자분들이 무조건 에러 응답들을 받아보실 텐데요..
우리 백엔드 미들웨어에서 라시니아 내부에서 뱉는 max-complexity 관련 에러는 로깅이나 슬랙만 보내고 by pass 한다던지의 의사결정만 될 듯 합니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한 10만 정도로 설정해두고,
error에 code 하나 심어서 해당 code가 에러에 잡히면 근재님께서 제안해준 것 처럼 로깅이나 슬랙만 보내고 by pass하는게 좋을거같네요!

result (do
(resolve/on-deliver! execution-result *result)
;; Block on that deliver, then return the final result.
Expand Down
47 changes: 47 additions & 0 deletions src/com/walmartlabs/lacinia/complexity_analysis.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
(ns com.walmartlabs.lacinia.complexity-analysis
(:require [com.walmartlabs.lacinia.selection :as selection]))

(defn ^:private list-args? [arguments]
(some? (or (:first arguments)
(:last arguments))))

(defn ^:private summarize-selection
"Recursively summarizes the selection, handling field, inline fragment, and named fragment."
[{:keys [arguments selections field-name leaf? fragment-name] :as selection} fragment-map]
(let [selection-kind (selection/selection-kind selection)]
(cond
;; If it's a leaf node or `pageInfo`, return nil.
(or leaf? (= :pageInfo field-name))
nil

;; If it's a named fragment, look it up in the fragment-map and process its selections.
(= :named-fragment selection-kind)
(let [sub-selections (:selections (fragment-map fragment-name))]
(mapcat #(summarize-selection % fragment-map) sub-selections))

;; If it's an inline fragment or `edges` field, process its selections.
(or (= :inline-fragment selection-kind) (= field-name :edges))
(mapcat #(summarize-selection % fragment-map) selections)

;; Otherwise, handle a regular field with potential nested selections.
:else
(let [n-nodes (or (-> arguments (select-keys [:first :last]) vals first) 1)]
[{:field-name field-name
:selections (mapcat #(summarize-selection % fragment-map) selections)
:list-args? (list-args? arguments)
:n-nodes n-nodes}]))))

(defn ^:private calculate-complexity
[{:keys [selections list-args? n-nodes]}]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 여기서 (seq selections) 체크가 필요한 것은 아닐지요...

Copy link
Member

@sookcha sookcha Aug 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재귀 돌면서 leaf 가 selection이 없을 때의 동작이 정의되어 있지 않아 보여서요
수정: 꼭 leaf일 필요는 없겠네요

(let [children-complexity (apply + (map calculate-complexity selections))]
(if list-args?
(* n-nodes children-complexity)
(+ n-nodes children-complexity))))

(defn complexity-analysis
[query {:keys [max-complexity] :as _options}]
(let [{:keys [fragments selections]} query
summarized-selections (mapcat #(summarize-selection % fragments) selections)
complexity (calculate-complexity (first summarized-selections))]
(when (> complexity max-complexity)
{:message (format "Over max complexity! Current number of resources to be queried: %s" complexity)})))
148 changes: 148 additions & 0 deletions test/com/walmartlabs/lacinia/complexity_analysis_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
; Copyright (c) 2017-present Walmart, Inc.
;
; Licensed under the Apache License, Version 2.0 (the "License")
; you may not use this file except in compliance with the License.
; You may obtain a copy of the License at
;
; http://www.apache.org/licenses/LICENSE-2.0
;
; Unless required by applicable law or agreed to in writing, software
; distributed under the License is distributed on an "AS IS" BASIS,
; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
; See the License for the specific language governing permissions and
; limitations under the License.

(ns com.walmartlabs.lacinia.complexity-analysis-test
(:require
[clojure.test :refer [deftest is run-test testing]]
[com.walmartlabs.lacinia :refer [execute]]
[com.walmartlabs.test-utils :as utils]))


(defn ^:private resolve-products
[_ _ _]
{:edges []
:pageInfo {}})

(defn ^:private resolve-followings
[_ _ _]
{:edges []
:pageInfo {}})

(defn ^:private resolve-reviews
[_ _ _]
{:edges []
:pageInfo {}})

(defn ^:private resolve-likers
[_ _ _]
{:edges []
:pageInfo {}})

(defn ^:private resolve-node
[_ _ _]
{:edges []
:pageInfo {}})

(def ^:private schema
(utils/compile-schema "complexity-analysis-error.edn"
{:resolve-products resolve-products
:resolve-followings resolve-followings
:resolve-reviews resolve-reviews
:resolve-likers resolve-likers
:resolve-node resolve-node}))

(defn ^:private q [query variables]
(utils/simplify (execute schema query variables nil {:max-complexity 10})))

(deftest over-complexity-analysis
(testing "It is possible to calculate the complexity of a query in the Relay connection spec
by taking into account both named fragments and inline fragments."
(is (= {:errors {:message "Over max complexity! Current number of resources to be queried: 27"}}
(q "query ProductDetail($productId: ID){
node(id: $productId) {
... on Product {
...ProductLikersFragment
seller{
id
products(first: 5){
edges{
node{
id
}
}
}
}
reviews(first: 5){
edges{
node{
id
author{
id
}
}
}
}
}
}
}
fragment ProductLikersFragment on Product {
likers(first: 10){
edges{
node{
... on Seller{
id
}
... on Buyer{
id
}
}
}
}
}" {:productId "id"}))))
(testing "If no arguments are passed in the query, the calculation uses the default value defined in the schema."
(is (= {:errors {:message "Over max complexity! Current number of resources to be queried: 22"}}
(q "query ProductDetail($productId: ID){
node(id: $productId) {
... on Product {
...ProductLikersFragment
seller{
id
products(first: 5){
edges{
node{
id
}
}
}
}
reviews(first: 5){
edges{
node{
id
author{
id
}
}
}
}
}
}
}
fragment ProductLikersFragment on Product {
likers{
edges{
node{
... on Seller{
id
}
... on Buyer{
id
}
}
}
}
}" {:productId "id"})))))

(comment
(run-test over-complexity-analysis))
Loading