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

Load simulator #3125

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
15 changes: 15 additions & 0 deletions env/dev/clj/rems.clj
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,22 @@
(rems.main/stop-app)
(repl/refresh :after 'rems.main/start-app))

(defn start-load-simulator []
(rems.main/start-load-simulator {:simulator {:url "http://localhost:3000/"
:concurrency 8}}))

(defn stop-load-simulator []
(stop-app))

(defn pptransit []
(rems.repl-utils/pptransit))

(def kaocha kaocha.repl/run)

(comment
(start-app)
(reload)
(refresh)
(start-load-simulator)
(stop-load-simulator)
)
20 changes: 11 additions & 9 deletions project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@
[clj-pdf "2.6.1"]
[clj-time "0.15.2"]
[com.attendify/schema-refined "0.3.0-alpha5"]
[com.clojure-goes-fast/clj-memory-meter "0.2.1"]
[com.draines/postal "2.0.5"]
[com.fasterxml.jackson.datatype/jackson-datatype-joda "2.14.1"]
[com.icegreen/greenmail "1.6.12"]
[com.stuartsierra/dependency "1.0.0"]
[com.rpl/specter "1.1.4"]
[com.taoensso/tempura "1.2.1"] ; 1.5.3 fails Wrong number of args (2) passed to: taoensso.tempura.impl/eval38628/compile-dictionary--38641, there must be a backwards incompatible change somewhere
[compojure "1.7.0"]
[conman "0.8.4"] ; 0.8.5 switches to next.jdbc, which breaks stuff and requires proper testing in production
[cprop "0.1.19"]
[criterium "0.4.6"]
[etaoin "1.0.39"]
[garden "1.3.10"]
[hiccup "1.0.5"]
[com.cognitect/transit-clj "1.0.329"]
Expand All @@ -28,6 +32,7 @@
[luminus-nrepl "0.1.7"]
[luminus/ring-ttl-session "0.3.3"]
[macroz/hiccup-find "0.6.1"]
[macroz/tangle "0.2.2"]
[markdown-clj "1.11.4"]
[medley "1.4.0"]
[metosin/compojure-api "2.0.0-alpha30" :exclusions [cheshire com.fasterxml.jackson.core/jackson-core]]
Expand Down Expand Up @@ -60,12 +65,14 @@
[ring/ring-core "1.9.6"]
[ring/ring-defaults "0.3.4"]
[ring/ring-devel "1.9.6"]
[ring/ring-mock "0.4.0" :exclusions [cheshire]]
[ring/ring-servlet "1.9.6"]
[se.haleby/stub-http "0.2.14"]
[nano-id "1.0.0"]]

:min-lein-version "2.9.8"

:source-paths ["src/clj" "src/cljc"]
:source-paths ["src/clj" "src/cljc" "test/clj" "test/cljc"]
:java-source-paths ["src/java"]
:javac-options ["-source" "8" "-target" "8"]
:test-paths ["src/clj" "src/cljc" "test/clj" "test/cljc"] ; also run tests from src files
Expand Down Expand Up @@ -113,15 +120,8 @@
:test [:project/dev :project/test :profiles/test]

:project/dev {:dependencies [[binaryage/devtools "1.0.6"]
[com.clojure-goes-fast/clj-memory-meter "0.2.1"]
[criterium "0.4.6"]
[lambdaisland/kaocha "1.80.1274"]
[lambdaisland/kaocha-junit-xml "1.17.101"]
[etaoin "1.0.39"]
[ring/ring-mock "0.4.0" :exclusions [cheshire]]
[se.haleby/stub-http "0.2.14"]
[com.icegreen/greenmail "1.6.12"]
[macroz/tangle "0.2.2"]]
[lambdaisland/kaocha-junit-xml "1.17.101"]]

:plugins [[lein-ancient "0.6.15"]]

Expand All @@ -132,7 +132,9 @@
:resource-paths ["env/dev/resources"]
:repl-options {:init-ns rems
:welcome (rems/repl-help)}}

:project/test {:jvm-opts ["-Drems.config=test-config.edn"]
:resource-paths ["env/test/resources"]}

:profiles/dev {}
:profiles/test {}})
172 changes: 172 additions & 0 deletions src/clj/rems/experimental/load_simulator.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
(ns rems.experimental.load-simulator
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this doesn't need to be marked as experimental. Maybe simulator package? Or even load-simulator if there won't be others. It's perhaps also not a traditional load testing (because it's rather heavy, uses real browser etc.) but definitely it is a "user simulator".

"Run load simulator from REMS. Simulator is configured with CLI options,
and can be run standalone or using REPL. Functions are provided for simulating
concurrent users via headless webdriver using etaoin."
(:require [clj-time.core :as time]
[clojure.pprint :refer [pprint]]
[clojure.set]
[clojure.tools.logging :as log]
[mount.core :as mount]
[rems.browser-test-util :as btu]
[rems.common.util :refer [getx parse-int]]
[rems.db.applications]
[rems.logging :refer [with-mdc]]
[rems.scheduler :as scheduler]
[rems.service.test-data :as test-data]
[rems.test-browser :as b]
[rems.experimental.simulator-util :as simu]
[rems.util :refer [rand-nth*]])
(:import [java.util.concurrent Executors ExecutorService TimeUnit]))

(def ^:private task-counter (atom 0))
(def ^:private current-tasks (atom {}))
(def ^:private task-statistics (atom {:completed []
:failed 0}))

(defn submit-new-application []
(simu/with-test-browser
(b/login-as (btu/context-get :user-id))
(btu/context-assoc! :cat-item (simu/get-random-catalogue-item))
(b/go-to-catalogue)
(b/add-to-cart (btu/context-get :cat-item))
(b/click-cart-apply)
(btu/context-assoc! :application-id (parse-int (b/get-application-id)))
(simu/fill-application-fields (btu/context-get :application-id))
(Thread/sleep 2000) ; wait for ui to catch up
(when (btu/exists? :accept-licenses-button)
(b/accept-licenses))
(b/send-application)
(b/logout)))

;; XXX: add more actions, e.g. view pdf, return/approve/reject
(defn handle-application []
(simu/with-test-browser
(b/login-as (btu/context-get :user-id))
(btu/context-assoc! :application-id (-> (btu/context-get :user-id)
(simu/get-random-todo-application)
:application/id))
(b/go-to-application (btu/context-get :application-id))
(btu/scroll-and-click :remark-action-button)
(let [selector {:id (b/get-field-id "Add remark")}]
(btu/fill-human selector (test-data/random-long-string 5)))
(btu/scroll-and-click :remark)
(btu/wait-visible :status-success)
(b/logout)))

(defn view-application []
(simu/with-test-browser
(b/login-as (btu/context-get :user-id))
(b/go-to-applications)
(-> (btu/context-get :user-id)
(simu/get-random-application)
:application/id
(b/go-to-application))
(b/logout)))

(comment
; convenience for development
(btu/init-driver! :chrome "http://localhost:3000/" :development)

(btu/context-assoc! :user-id "alice")
(view-application)

(btu/context-assoc! :user-id "handler")
(handle-application)

(btu/context-assoc! :user-id "alice")
(with-redefs [simu/get-random-catalogue-item (constantly "Default workflow 2")]
(submit-new-application)))

(defn get-task-action [user-id]
(let [roles (rems.db.applications/get-all-application-roles user-id)
actions (case (rand-nth* roles)
:handler [handle-application]
[submit-new-application view-application])]
(rand-nth* actions)))

(defn get-task-user! [task-id]
(locking current-tasks
(let [current-users (map :user-id (vals @current-tasks))
available-users (-> (simu/get-all-users)
Copy link
Collaborator

Choose a reason for hiding this comment

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

On my machine it tries to use perf tester users which are not present on the login screen. I think overall we must still figure out a solution for specifying the users.

(clojure.set/difference (set current-users)))]
(when-some [user-id (rand-nth* available-users)]
(swap! current-tasks assoc-in [task-id :user-id] user-id)
user-id))))

(defn create-task! [url]
(let [task-id (swap! task-counter inc)
task-context (atom (assoc (btu/create-base-context)
:url url
:seed "simulator"
:task-id task-id))]
(swap! current-tasks assoc task-id {})
(fn simulate []
(log/info "Simulator thread starting")
(binding [btu/*test-context* task-context]
(try
(btu/init-driver! :chrome (btu/get-server-url))
(while true
(let [start (System/nanoTime)]
(when-some [user-id (get-task-user! task-id)]
(btu/context-assoc! :user-id user-id)
(with-mdc {:userid user-id}
(log/debug "task >")
(apply (get-task-action user-id) [])
(let [execution-time (int (/ (- (System/nanoTime) start) 1000000))]
(swap! current-tasks update task-id dissoc :user-id)
(swap! task-statistics update :completed conj execution-time))
(log/debug "task <")))))
(catch InterruptedException e
(.interrupt (Thread/currentThread))
(log/info e "Simulator thread interrupted"))
(catch Throwable t
(log/error t "Internal error" (with-out-str
(pprint (merge {::context @task-context}
(ex-data t)))))
(swap! task-statistics update :failed inc))
(finally
(log/info "Simulator thread shutting down")
(btu/stop-existing-driver!)
(swap! current-tasks dissoc task-id)))))))

(defn validate [opts]
(when-some [simulator (:simulator opts)]
{:url (getx simulator :url)
:concurrency (getx simulator :concurrency)}))

(mount/defstate simulator-thread-pool
:start (when (validate (mount/args))
(Executors/newCachedThreadPool))
:stop (when simulator-thread-pool
(.shutdownNow simulator-thread-pool)
(when-not (.awaitTermination simulator-thread-pool 5 TimeUnit/MINUTES)
(throw (IllegalStateException. "did not terminate")))))

(defn start-simulator-threads! [{:keys [url concurrency]}]
(let [startable (- concurrency (count @current-tasks))]
(dotimes [_ startable]
(.submit ^ExecutorService simulator-thread-pool
^Callable (create-task! url)))))

(defn print-simulator-statistics []
(let [completed (:completed @task-statistics)
completed-count (count completed)
failed-count (:failed @task-statistics)
average-execution-time (when (pos? completed-count)
(int (/ (reduce + completed)
completed-count)))]
(log/info "statistics:"
(format "active_threads=%d, completed_tasks=%d, failed_tasks=%d, task_avg_execution_time=%dms"
(count @current-tasks) completed-count failed-count (or average-execution-time 0)))))

(mount/defstate queue-simulate-tasks
:start (when-some [opts (validate (mount/args))]
(log/info 'queue-simulate-tasks opts)
(start-simulator-threads! opts)
(scheduler/start! "simulate-tasks"
#(do (print-simulator-statistics)
(start-simulator-threads! opts))
(.toStandardDuration (time/seconds 15))))
:stop (when queue-simulate-tasks
(scheduler/stop! queue-simulate-tasks)))

78 changes: 78 additions & 0 deletions src/clj/rems/experimental/simulator_util.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
(ns rems.experimental.simulator-util
(:require [clojure.set]
[com.rpl.specter :refer [ALL select]]
[rems.browser-test-util :as btu]
[rems.db.applications]
[rems.db.catalogue]
[rems.db.roles]
[rems.db.test-data-users :refer [+bot-users+]]
[rems.db.users]
[rems.service.test-data :as test-data]
[rems.service.todos]
[rems.test-browser :as b]
[rems.text :refer [get-localized-title]]
[rems.util :refer [rand-nth*]]))

(defmacro with-test-browser [& body]
`(binding [btu/screenshot (constantly nil)
btu/screenshot-element (constantly nil)
btu/check-axe (constantly nil)
btu/postmortem-handler (constantly nil)]
(btu/refresh-driver!)
~@body))

(def bot-userids (set (vals +bot-users+)))

(defn get-db-roles [user-id]
(let [roles (rems.db.roles/get-roles user-id)]
(disj roles :logged-in)))

(defn get-all-users []
(let [users (rems.db.users/get-users)]
(->> users
(map :userid)
(remove #(contains? bot-userids %))
(remove #(seq (get-db-roles %)))
(set))))

(defn get-application-fields [app-id]
(let [application (rems.db.applications/get-application-internal app-id)]
(->> application
(select [:application/forms ALL :form/fields ALL]))))

(defn get-random-application [user-id]
(let [applications (rems.db.applications/get-my-applications user-id)]
(rand-nth* applications)))

(defn get-random-todo-application [user-id]
(let [applications (rems.service.todos/get-todos user-id)]
(rand-nth* applications)))

(defn get-random-catalogue-item []
(let [catalogue-item (->> (rems.db.catalogue/get-localized-catalogue-items {:archived false :enabled true})
(remove :expired)
(rand-nth*))]
(get-localized-title catalogue-item :en)))

#_(defn fill-human [selector value]
(apply (btu/wrap-etaoin et/fill-human)
[selector value {:mistake-prob 0 :pause-max 0.1}]))

(defn fill-field [label value]
(let [selector {:id (b/get-field-id label)}]
(btu/fill-human selector value)))

(defn fill-application-fields [app-id]
(doseq [field (get-application-fields app-id)
:let [label (get-in field [:field/title :en])]
:when (:field/visible field) ; ignore conditional fields
:when (not (:field/optional field))] ; ignore optional fields
(when-some [value (case (:field/type field)
:description (btu/get-seed)
(:text :texta) (test-data/random-long-string 5)
:email "user@example.com"
:phone-number "+358451110000"
:ip-address "142.250.74.110"
nil)]
(fill-field label value))))

26 changes: 25 additions & 1 deletion src/clj/rems/main.clj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
[rems.db.core :as db]
[rems.db.fix-userid]
[rems.db.roles :as roles]
[rems.experimental.load-simulator]
[rems.service.test-data :as test-data]
[rems.db.users :as users]
[rems.handler :as handler]
Expand Down Expand Up @@ -99,6 +100,22 @@
(validate/validate)
(refresh-caches))

(def simulator-cli-options
[[nil "--url URL" "URL to run simulation against" :default "http://localhost:3000/"]
[nil "--concurrency CONCURRENCY" "Maximum concurrency" :default 8 :parse-fn #(Integer/parseInt %)]])

(defn start-load-simulator [opts]
(doseq [component (-> opts
(mount/start-with-args #'rems.config/env
#'rems.db.core/*db*
#'rems.locales/translations
#'rems.experimental.load-simulator/queue-simulate-tasks
#'rems.experimental.load-simulator/simulator-thread-pool)
:started)]
(log/info component "started"))
(.addShutdownHook (Runtime/getRuntime) (Thread. stop-app))
(applications/refresh-all-applications-cache!))

;; The default of the JVM is to exit with code 128+signal. However, we
;; shut down gracefully on SIGINT and SIGTERM due to the exit hooks
;; mount has installed. Thus exit code 0 is the right choice. This
Expand Down Expand Up @@ -142,7 +159,10 @@
\"api-key allow-all <api-key>\" -- clears the allowed method/path whitelist.
An empty list means all methods and paths are allowed.
\"ega api-key <userid> <username> <password> <config-id>\" -- generate a new API-Key for the user using EGA login
\"rename-user <old-userid> <new-userid>\" -- change a user's identity from old to new"
\"rename-user <old-userid> <new-userid>\" -- change a user's identity from old to new
\"load-simulator [--url] [--concurrency]\" -- start load simulator that runs concurrent headless browser instances against target REMS.
--url is optional for target REMS (defaults to http://localhost:3000/).
--concurrency is optional number of maximum concurrent threads (defaults to 8)."
[& args]
(exit-on-signals!)
(log/info "REMS" git/+version+)
Expand Down Expand Up @@ -266,6 +286,10 @@
(rems.db.fix-userid/fix-all old-userid new-userid simulate?)
(println "Finished.\n\nConsider rebooting the server process next to refresh all the caches, most importantly the application cache.")))))

"load-simulator"
(let [opts (:options (parse-opts args simulator-cli-options))]
(start-load-simulator {:simulator opts}))

(do
(println "Unrecognized argument:" (first args))
(usage)
Expand Down
Loading