Fence market-data writes and recover stalled Binance streams
pr / test (pull_request) Successful in 1m22s

This commit is contained in:
2026-09-19 16:16:48 +02:00
parent c549da40e1
commit 904f3fffe6
12 changed files with 336 additions and 129 deletions
@@ -1,26 +1,24 @@
(ns pmagnus.btcdata.web.controllers.health-test
(:require
[clojure.test :refer [deftest is testing]]
[pmagnus.btcdata.web.controllers.health :as health]))
(:require [clojure.test :refer [deftest is]]
[pmagnus.btcdata.web.controllers.health :as health]))
(def disabled-stream {:state (atom {:enabled? false}) :latest-price (atom nil)})
(deftest healthcheck-is-process-only
(let [response (health/healthcheck! {})]
(is (= 200 (:status response)))
(is (= "up" (get-in response [:body :app :status])))))
(is (= 200 (:status (health/healthcheck! {})))))
(deftest readiness-check-tests-database
(testing "ready when the database query succeeds"
(let [called (atom nil)
query-fn (fn [query params]
(reset! called [query params])
{:healthy 1})
response (health/readiness-check! query-fn {})]
(is (= 200 (:status response)))
(is (= {:status "ready"} (:body response)))
(is (= [:check-database {}] @called))))
(let [called (atom nil)
query-fn (fn [query params] (reset! called [query params]))]
(is (= 200 (:status (health/readiness-check! query-fn disabled-stream {}))))
(is (= [:check-database {}] @called)))
(is (= 503 (:status (health/readiness-check!
(fn [_ _] (throw (ex-info "database unavailable" {})))
disabled-stream {})))))
(testing "not ready when the database query fails"
(let [query-fn (fn [_ _] (throw (ex-info "database unavailable" {})))
response (health/readiness-check! query-fn {})]
(is (= 503 (:status response)))
(is (= {:status "not ready"} (:body response))))))
(deftest stale-stream-is-not-ready
(is (= 503 (:status (health/readiness-check!
(fn [_ _] {})
{:state (atom {:enabled? true :connected? true})
:latest-price (atom {:recorded-at java.time.Instant/EPOCH})}
{})))))
+52
View File
@@ -0,0 +1,52 @@
(ns pmagnus.btcdata.writer-test
(:require [clojure.test :refer [deftest is]]
[integrant.core :as ig]
[conman.core :as conman]
[next.jdbc :as jdbc]
[pmagnus.btcdata.writer :as writer]))
(deftest queries-use-lock-session
(let [connection (Object.)
seen (atom nil)
query (writer/guarded-query connection
(fn [& args] (reset! seen args) :result))]
(is (= :result (query :insert-price {:price 1})))
(is (= [connection :insert-price {:price 1}] @seen))))
(deftest postgres-session-fencing
;; Optional disposable PostgreSQL integration test; never use production here.
(if-let [url (System/getenv "BTCDATA_TEST_JDBC_URL")]
(let [ds (jdbc/get-datasource {:jdbcUrl url})
opts {:conn ds :query-fn (fn [conn _ _]
(jdbc/execute-one! conn ["select 1 as healthy"]))}
first-writer (ig/init-key :writer/query-fn opts)]
(try
(is (= 1 (:healthy (first-writer :check {}))))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Another btcdata writer"
(ig/init-key :writer/query-fn opts)))
;; Closing the owning session cannot transparently reconnect for writes.
(.close (:connection (meta first-writer)))
(is (thrown? Exception (first-writer :check {})))
(let [replacement (ig/init-key :writer/query-fn opts)]
(try
(is (= 1 (:healthy (replacement :check {}))))
(finally (ig/halt-key! :writer/query-fn replacement))))
(finally
(when-not (.isClosed (:connection (meta first-writer)))
(ig/halt-key! :writer/query-fn first-writer)))))
(println "SKIP PostgreSQL fencing integration: set BTCDATA_TEST_JDBC_URL")))
(deftest pooled-session-releases-lock-on-halt
(when-let [url (System/getenv "BTCDATA_TEST_JDBC_URL")]
(let [pool (conman/connect! {:jdbc-url url :maximum-pool-size 3})
opts {:conn pool :query-fn (fn [_ _ _] :ok)}]
(try
(let [leader (ig/init-key :writer/query-fn opts)]
(is (thrown? Exception (ig/init-key :writer/query-fn opts)))
(ig/halt-key! :writer/query-fn leader))
;; An independent connection can acquire after pooled-session shutdown.
(let [other-opts (assoc opts :conn (jdbc/get-datasource {:jdbcUrl url}))
leader (ig/init-key :writer/query-fn other-opts)]
(is (= :ok (leader :check {})))
(ig/halt-key! :writer/query-fn leader))
(finally (conman/disconnect! pool))))))
@@ -0,0 +1,41 @@
(ns pmagnus.btcdata.ws.binance-test
(:require [clojure.test :refer [deftest is]]
[integrant.core :as ig]
[pmagnus.btcdata.ws.binance :as binance])
(:import [java.time Instant]))
(deftest freshness-boundaries
(let [state (atom {:enabled? true :connected? true})
price (atom {:recorded-at (Instant/ofEpochMilli 100000)})
component {:state state :latest-price price}]
(is (binance/fresh? component 100000))
(is (binance/fresh? component 160000))
(is (not (binance/fresh? component 160001)))
(is (not (binance/fresh? component 99999)))
(swap! state assoc :connected? false)
(is (not (binance/fresh? component 100000)))
(reset! price nil)
(is (not (binance/fresh? component 100000)))))
(deftest disabled-stream-does-not-connect
(let [component (ig/init-key :ws/binance {:enabled? "false" :uri "invalid"})]
(is (binance/fresh? component))
(is (nil? (:worker component)))
(ig/halt-key! :ws/binance component)
(is (false? (:running? @(:state component))))))
(deftest initial-failure-is-retried-and-worker-stops
(let [attempts (atom 0)
connect-var (ns-resolve 'pmagnus.btcdata.ws.binance 'connect!)]
(with-redefs-fn
{connect-var (fn [& _] (swap! attempts inc) (throw (ex-info "test failure" {})))}
(fn []
(let [component (ig/init-key :ws/binance {:enabled? "true" :uri "test"})]
(try
(let [deadline (+ (System/currentTimeMillis) 6000)]
(while (and (< @attempts 2) (< (System/currentTimeMillis) deadline))
(Thread/sleep 20)))
(is (>= @attempts 2))
(is (not (binance/fresh? component)))
(finally (ig/halt-key! :ws/binance component)))
(is (future-cancelled? (:worker component))))))))