Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0843af83e | ||
|
|
904f3fffe6 |
@@ -16,13 +16,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Test
|
- name: Unit and PostgreSQL fencing tests
|
||||||
run: |
|
run: bash scripts/test-fencing.sh
|
||||||
docker run --rm \
|
|
||||||
-v "$PWD:/app" \
|
|
||||||
-w /app \
|
|
||||||
clojure:temurin-21-tools-deps-alpine \
|
|
||||||
clj -M:test
|
|
||||||
|
|
||||||
- name: Generate immutable tags
|
- name: Generate immutable tags
|
||||||
id: tags
|
id: tags
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name: pr
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Unit and PostgreSQL fencing tests
|
||||||
|
run: bash scripts/test-fencing.sh
|
||||||
|
# No registry, deployment, or production credentials in PR jobs.
|
||||||
@@ -1,5 +1,32 @@
|
|||||||
# btcdata Kubernetes deployment plan
|
# btcdata Kubernetes deployment plan
|
||||||
|
|
||||||
|
## 2026-09-19 status and proposed recovery
|
||||||
|
|
||||||
|
Kubernetes deployment already exists: one `Recreate` replica on citadel, eligible
|
||||||
|
on citadel and srv1893094. Both nodes were Ready. At 14:03 UTC the live price
|
||||||
|
WebSocket upgraded successfully but returned data last recorded at 08:36:42 UTC.
|
||||||
|
Harbor was refusing connections and Flux apps reconciliation was dependency-blocked.
|
||||||
|
No production restart or relocation was performed during this investigation.
|
||||||
|
|
||||||
|
The `fix/websocket-failover` application change proposes bounded Binance reconnects,
|
||||||
|
60-second in-memory freshness readiness, and PostgreSQL single-writer fencing.
|
||||||
|
Every application query uses the session holding advisory lock `728194106`; loss
|
||||||
|
of that session cannot cause writes through another pooled connection. Competing
|
||||||
|
instances fail startup rather than becoming simultaneous writers. This requires
|
||||||
|
all writers to run the fenced version; legacy deployments must stay stopped.
|
||||||
|
|
||||||
|
Companion deployment changes in `magnus/deploy` add a **suspended** watchdog:
|
||||||
|
three failed minute checks after startup grace request relocation to the other
|
||||||
|
healthy node, with a ten-minute cooldown. See `apps/btcdata/FAILOVER.md` in that
|
||||||
|
repository for review gates, acceptance tests, limitations, and rollback. This is
|
||||||
|
proposed code, not a claim that automatic failover is enabled in production.
|
||||||
|
|
||||||
|
Run `make test` for unit tests. Run `bash scripts/test-fencing.sh` for unit tests
|
||||||
|
plus PostgreSQL lock-contention, session-loss, and pooled-lock-release tests in
|
||||||
|
isolated disposable containers. No production credentials are required.
|
||||||
|
|
||||||
|
The remaining sections below are the original migration plan, not current status.
|
||||||
|
|
||||||
## Goal and interpretation
|
## Goal and interpretation
|
||||||
|
|
||||||
Deploy `btcdata` through the existing Flux-managed two-node k3s cluster (`srv1893094` and `citadel`), with **exactly one btcdata pod running at a time**. The pod may run on either node and Kubernetes may recreate it on the other node after failure.
|
Deploy `btcdata` through the existing Flux-managed two-node k3s cluster (`srv1893094` and `citadel`), with **exactly one btcdata pod running at a time**. The pod may run on either node and Kubernetes may recreate it on the other node after failure.
|
||||||
|
|||||||
+12
-7
@@ -43,36 +43,41 @@
|
|||||||
:db {:datasource #ig/ref :db.sql/connection}
|
:db {:datasource #ig/ref :db.sql/connection}
|
||||||
:migrate-on-init? true}
|
:migrate-on-init? true}
|
||||||
|
|
||||||
|
:writer/query-fn
|
||||||
|
{:conn #ig/ref :db.sql/connection
|
||||||
|
:query-fn #ig/ref :db.sql/query-fn
|
||||||
|
:migrations #ig/ref :db.sql/migrations}
|
||||||
|
|
||||||
:reitit.routes/api
|
:reitit.routes/api
|
||||||
{:base-path "/api"
|
{:base-path "/api"
|
||||||
:query-fn #ig/ref :db.sql/query-fn
|
:query-fn #ig/ref :writer/query-fn
|
||||||
:binance #ig/ref :ws/binance
|
:binance #ig/ref :ws/binance
|
||||||
:frankfurter #ig/ref :frankfurter/rates
|
:frankfurter #ig/ref :frankfurter/rates
|
||||||
:strike #ig/ref :strike/ticker}
|
:strike #ig/ref :strike/ticker}
|
||||||
|
|
||||||
:ws/binance
|
:ws/binance
|
||||||
{:query-fn #ig/ref :db.sql/query-fn
|
{:query-fn #ig/ref :writer/query-fn
|
||||||
:uri "wss://stream.binance.com:9443/ws/btcusdt@trade"
|
:uri "wss://stream.binance.com:9443/ws/btcusdt@trade"
|
||||||
:enabled? #or [#env BINANCE_ENABLED "true"]}
|
:enabled? #or [#env BINANCE_ENABLED "true"]}
|
||||||
|
|
||||||
:kraken/ohlc
|
:kraken/ohlc
|
||||||
{:query-fn #ig/ref :db.sql/query-fn
|
{:query-fn #ig/ref :writer/query-fn
|
||||||
:enabled? #or [#env KRAKEN_ENABLED "true"]}
|
:enabled? #or [#env KRAKEN_ENABLED "true"]}
|
||||||
|
|
||||||
:kraken/ohlc-day
|
:kraken/ohlc-day
|
||||||
{:query-fn #ig/ref :db.sql/query-fn
|
{:query-fn #ig/ref :writer/query-fn
|
||||||
:enabled? #or [#env KRAKEN_ENABLED "true"]}
|
:enabled? #or [#env KRAKEN_ENABLED "true"]}
|
||||||
|
|
||||||
:kraken/ohlc-minute
|
:kraken/ohlc-minute
|
||||||
{:query-fn #ig/ref :db.sql/query-fn
|
{:query-fn #ig/ref :writer/query-fn
|
||||||
:enabled? #or [#env KRAKEN_ENABLED "true"]}
|
:enabled? #or [#env KRAKEN_ENABLED "true"]}
|
||||||
|
|
||||||
:frankfurter/rates
|
:frankfurter/rates
|
||||||
{:url #or [#env FRANKFURTER_URL "http://localhost:8080"]
|
{:url #or [#env FRANKFURTER_URL "http://localhost:8080"]
|
||||||
:query-fn #ig/ref :db.sql/query-fn}
|
:query-fn #ig/ref :writer/query-fn}
|
||||||
|
|
||||||
:strike/ticker
|
:strike/ticker
|
||||||
{:query-fn #ig/ref :db.sql/query-fn
|
{:query-fn #ig/ref :writer/query-fn
|
||||||
:api-key #env STRIKE_API_KEY
|
:api-key #env STRIKE_API_KEY
|
||||||
:sats-eur-amount #or [#env SATS_EUR_AMOUNT "55"]
|
:sats-eur-amount #or [#env SATS_EUR_AMOUNT "55"]
|
||||||
:enabled? #or [#env STRIKE_ENABLED "true"]}}
|
:enabled? #or [#env STRIKE_ENABLED "true"]}}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Disposable isolated PostgreSQL; no production credentials or published ports.
|
||||||
|
set -euo pipefail
|
||||||
|
name="btcdata-test-${GITHUB_RUN_ID:-$$}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
cleanup() {
|
||||||
|
docker rm -f "${name}-runner" "$name" >/dev/null 2>&1 || true
|
||||||
|
docker network rm "$name" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
docker network create "$name" >/dev/null
|
||||||
|
docker run -d --name "$name" --network "$name" \
|
||||||
|
-e POSTGRES_HOST_AUTH_METHOD=trust postgres:16-alpine >/dev/null
|
||||||
|
ready=false
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if docker exec "$name" pg_isready -U postgres >/dev/null 2>&1; then
|
||||||
|
ready=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [ "$ready" != true ]; then
|
||||||
|
echo 'Disposable PostgreSQL did not become ready' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# The CI workspace is inside the runner container, not on the Docker host.
|
||||||
|
# Copy only test inputs; never mount a nonexistent host workspace or copy .env/.git.
|
||||||
|
docker create --name "${name}-runner" --network "$name" -w /app \
|
||||||
|
-e "BTCDATA_TEST_JDBC_URL=jdbc:postgresql://$name:5432/postgres?user=postgres" \
|
||||||
|
clojure:temurin-21-tools-deps-alpine clj -M:test >/dev/null
|
||||||
|
COPYFILE_DISABLE=1 tar --no-xattrs -cf - deps.edn src resources test env/test | docker cp - "${name}-runner:/app"
|
||||||
|
docker start --attach "${name}-runner"
|
||||||
|
exit "$(docker inspect --format '{{.State.ExitCode}}' "${name}-runner")"
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
[pmagnus.btcdata.web.routes.api]
|
[pmagnus.btcdata.web.routes.api]
|
||||||
;; WebSocket clients
|
;; WebSocket clients
|
||||||
[pmagnus.btcdata.ws.binance]
|
[pmagnus.btcdata.ws.binance]
|
||||||
|
[pmagnus.btcdata.writer]
|
||||||
;; Pollers
|
;; Pollers
|
||||||
[pmagnus.btcdata.kraken.ohlc]
|
[pmagnus.btcdata.kraken.ohlc]
|
||||||
[pmagnus.btcdata.kraken.ohlc-daily]
|
[pmagnus.btcdata.kraken.ohlc-daily]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
(ns pmagnus.btcdata.web.controllers.health
|
(ns pmagnus.btcdata.web.controllers.health
|
||||||
(:require
|
(:require
|
||||||
[clojure.tools.logging :as log]
|
[clojure.tools.logging :as log]
|
||||||
[ring.util.http-response :as response])
|
[ring.util.http-response :as response]
|
||||||
|
[pmagnus.btcdata.ws.binance :as binance])
|
||||||
(:import
|
(:import
|
||||||
[java.lang.management ManagementFactory]))
|
[java.lang.management ManagementFactory]))
|
||||||
|
|
||||||
@@ -12,11 +13,13 @@
|
|||||||
:app {:status "up"}}))
|
:app {:status "up"}}))
|
||||||
|
|
||||||
(defn readiness-check!
|
(defn readiness-check!
|
||||||
"Report readiness only while the application can query PostgreSQL."
|
"Require the lock-owning database session and a fresh upstream stream."
|
||||||
[query-fn _req]
|
[query-fn component _req]
|
||||||
(try
|
(try
|
||||||
(query-fn :check-database {})
|
(query-fn :check-database {})
|
||||||
(response/ok {:status "ready"})
|
(if (binance/fresh? component)
|
||||||
|
(response/ok {:status "ready"})
|
||||||
|
(response/service-unavailable {:status "not ready" :reason "binance stale"}))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/warn e "Database readiness check failed")
|
(log/warn e "Database readiness check failed")
|
||||||
(response/service-unavailable {:status "not ready"}))))
|
(response/service-unavailable {:status "not ready"}))))
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
["/health"
|
["/health"
|
||||||
{:get health/healthcheck!}]
|
{:get health/healthcheck!}]
|
||||||
["/ready"
|
["/ready"
|
||||||
{:get (fn [req] (health/readiness-check! (:query-fn opts) req))}]
|
{:get (fn [req] (health/readiness-check! (:query-fn opts) (:binance opts) req))}]
|
||||||
["/price/ws"
|
["/price/ws"
|
||||||
{:get (fn [req] (ws-price-handler opts req))
|
{:get (fn [req] (ws-price-handler opts req))
|
||||||
:no-doc true
|
:no-doc true
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
(ns pmagnus.btcdata.writer
|
||||||
|
"Fence all application queries with one PostgreSQL session advisory lock.
|
||||||
|
Queries use the lock-owning connection itself: a lost session cannot keep
|
||||||
|
writing through a different pooled connection. Never reconnect this session."
|
||||||
|
(:require [integrant.core :as ig]
|
||||||
|
[next.jdbc :as jdbc]))
|
||||||
|
|
||||||
|
(def lock-id 728194106)
|
||||||
|
|
||||||
|
(defn guarded-query [connection query-fn]
|
||||||
|
(fn [query params]
|
||||||
|
(locking connection
|
||||||
|
(query-fn connection query params))))
|
||||||
|
|
||||||
|
(defmethod ig/init-key :writer/query-fn
|
||||||
|
[_ {:keys [conn query-fn]}]
|
||||||
|
(let [connection (jdbc/get-connection conn)]
|
||||||
|
(try
|
||||||
|
;; Bound lock/query stalls; a failed session is never replaced behind the fence.
|
||||||
|
(.setNetworkTimeout connection
|
||||||
|
(reify java.util.concurrent.Executor (execute [_ task] (.run ^Runnable task)))
|
||||||
|
10000)
|
||||||
|
(when-not (:locked (jdbc/execute-one! connection
|
||||||
|
["select pg_try_advisory_lock(?) as locked" lock-id]))
|
||||||
|
(throw (ex-info "Another btcdata writer holds the database lock" {})))
|
||||||
|
(with-meta (guarded-query connection query-fn) {:connection connection})
|
||||||
|
(catch Exception e
|
||||||
|
(.close connection)
|
||||||
|
(throw e)))))
|
||||||
|
|
||||||
|
(defmethod ig/halt-key! :writer/query-fn [_ query-fn]
|
||||||
|
(let [connection (:connection (meta query-fn))]
|
||||||
|
(locking connection
|
||||||
|
(try
|
||||||
|
(jdbc/execute-one! connection ["select pg_advisory_unlock(?)" lock-id])
|
||||||
|
(finally (.close connection))))))
|
||||||
@@ -1,97 +1,102 @@
|
|||||||
(ns pmagnus.btcdata.ws.binance
|
(ns pmagnus.btcdata.ws.binance
|
||||||
(:require
|
(:require [clojure.data.json :as json]
|
||||||
[clojure.data.json :as json]
|
[clojure.tools.logging :as log]
|
||||||
[clojure.tools.logging :as log]
|
[integrant.core :as ig])
|
||||||
[integrant.core :as ig])
|
(:import [java.net URI]
|
||||||
(:import
|
[java.net.http HttpClient WebSocket WebSocket$Listener]
|
||||||
[java.net URI]
|
[java.time Duration Instant]
|
||||||
[java.net.http HttpClient WebSocket WebSocket$Listener]
|
[java.util.concurrent CompletableFuture TimeUnit]))
|
||||||
[java.util.concurrent CompletableFuture CompletionStage]))
|
|
||||||
|
|
||||||
(defn- save-price! [query-fn price]
|
(def stale-after-ms 60000)
|
||||||
(try
|
|
||||||
(query-fn :insert-binance-price! {:price price})
|
|
||||||
(catch Exception e
|
|
||||||
(log/error e "Failed to save BTC price"))))
|
|
||||||
|
|
||||||
(defn- connect!
|
(defn fresh?
|
||||||
"Opens a WebSocket to Binance trade stream and returns the WebSocket instance.
|
"Only in-process stream data counts; database fallback is not stream health."
|
||||||
`state` is an atom with keys :running?, :last-write, :buffer.
|
([component] (fresh? component (System/currentTimeMillis)))
|
||||||
`latest-price` is an atom reset with each throttled price for SSE consumers."
|
([{:keys [state latest-price]} now]
|
||||||
[uri query-fn state latest-price]
|
(let [{:keys [enabled? connected?]} @state
|
||||||
(let [client (HttpClient/newHttpClient)
|
recorded-at (:recorded-at @latest-price)]
|
||||||
listener (reify WebSocket$Listener
|
(or (false? enabled?)
|
||||||
(onOpen [_ ws]
|
(and connected? (some? recorded-at)
|
||||||
(log/info "Binance WebSocket connected")
|
(<= 0 (- now (.toEpochMilli ^Instant recorded-at)) stale-after-ms))))))
|
||||||
(.request ws 1))
|
|
||||||
|
|
||||||
(onText [_ ws data last?]
|
(defn- connect! [client uri query-fn state latest-price generation]
|
||||||
(let [buf (:buffer @state)]
|
(let [buffer (StringBuilder.)
|
||||||
(.append buf data)
|
current? #(and (:running? @state) (= generation (:generation @state)))
|
||||||
(when last?
|
listener
|
||||||
(let [text (str buf)]
|
(reify WebSocket$Listener
|
||||||
(.setLength buf 0)
|
(onOpen [_ ws]
|
||||||
(try
|
(if (current?)
|
||||||
(let [msg (json/read-str text :key-fn keyword)
|
(do (swap! state assoc :connected? true)
|
||||||
price (some-> (:p msg) bigdec)]
|
(log/info "Binance WebSocket connected")
|
||||||
(when price
|
(.request ws 1))
|
||||||
(let [now (System/currentTimeMillis)]
|
(.abort ws)))
|
||||||
(when (> (- now (:last-write @state)) 5000)
|
(onText [_ ws data last?]
|
||||||
(swap! state assoc :last-write now)
|
(when (current?)
|
||||||
(log/info "BTC price:" (str price))
|
(.append buffer data)
|
||||||
(save-price! query-fn price)
|
(when last?
|
||||||
(let [prev-price (:price @latest-price)]
|
(let [text (str buffer)]
|
||||||
(reset! latest-price
|
(.setLength buffer 0)
|
||||||
{:price price
|
(try
|
||||||
:prev-price prev-price
|
(when-let [price (some-> (json/read-str text :key-fn keyword) :p bigdec)]
|
||||||
:recorded-at (java.time.Instant/now)}))))))
|
(let [now (System/currentTimeMillis)]
|
||||||
(catch Exception e
|
(swap! state assoc :last-message now)
|
||||||
(log/error e "Failed to parse Binance message")))))
|
(when (> (- now (:last-write @state)) 5000)
|
||||||
(let [^CompletionStage cf (CompletableFuture/completedFuture nil)]
|
;; Publish only after persistence succeeds.
|
||||||
(.request ws 1)
|
(query-fn :insert-binance-price! {:price price})
|
||||||
cf)))
|
(swap! state assoc :last-write now)
|
||||||
|
(reset! latest-price {:price price
|
||||||
(onClose [_ _ws code reason]
|
:prev-price (:price @latest-price)
|
||||||
(log/warn "Binance WebSocket closed:" code reason)
|
:recorded-at (Instant/now)}))))
|
||||||
(when (:running? @state)
|
(catch Exception e
|
||||||
(future
|
(log/error e "Failed to process Binance price"))))))
|
||||||
(Thread/sleep 3000)
|
(.request ws 1)
|
||||||
(when (:running? @state)
|
(CompletableFuture/completedFuture nil))
|
||||||
(log/info "Reconnecting to Binance...")
|
(onClose [_ _ code reason]
|
||||||
(try
|
(when (current?)
|
||||||
(let [ws (connect! uri query-fn state latest-price)]
|
(swap! state assoc :connected? false)
|
||||||
(swap! state assoc :ws ws))
|
(log/warn "Binance WebSocket closed:" code reason))
|
||||||
(catch Exception e
|
(CompletableFuture/completedFuture nil))
|
||||||
(log/error e "Binance reconnect failed")))))))
|
(onError [_ _ error]
|
||||||
|
(when (current?)
|
||||||
(onError [_ _ws error]
|
(swap! state assoc :connected? false)
|
||||||
(log/error error "Binance WebSocket error")))]
|
(log/warn error "Binance WebSocket error"))))]
|
||||||
(-> (.newWebSocketBuilder client)
|
(-> (.newWebSocketBuilder ^HttpClient client)
|
||||||
|
(.connectTimeout (Duration/ofSeconds 10))
|
||||||
(.buildAsync (URI. uri) listener)
|
(.buildAsync (URI. uri) listener)
|
||||||
(.join))))
|
(.get 15 TimeUnit/SECONDS))))
|
||||||
|
|
||||||
(defmethod ig/init-key :ws/binance
|
(defmethod ig/init-key :ws/binance
|
||||||
[_ {:keys [query-fn uri enabled?] :or {enabled? "true"}}]
|
[_ {:keys [query-fn uri enabled?] :or {enabled? "true"}}]
|
||||||
(if-not (= enabled? "true")
|
(let [enabled (= enabled? "true")
|
||||||
(do (log/info "Binance WebSocket disabled")
|
latest-price (atom nil)
|
||||||
{:state (atom {:running? false})
|
state (atom {:enabled? enabled :running? enabled :connected? false
|
||||||
:latest-price (atom nil)})
|
:last-write 0 :last-message 0 :generation nil :ws nil})
|
||||||
(do (log/info "Starting Binance WebSocket listener:" uri)
|
client (when enabled (-> (HttpClient/newBuilder)
|
||||||
(let [latest-price (atom nil)
|
(.connectTimeout (Duration/ofSeconds 10)) .build))
|
||||||
state (atom {:running? true
|
worker
|
||||||
:last-write 0
|
(when enabled
|
||||||
:buffer (StringBuilder.)
|
(future
|
||||||
:ws nil})
|
(try
|
||||||
ws (connect! uri query-fn state latest-price)]
|
(while (:running? @state)
|
||||||
(swap! state assoc :ws ws)
|
(let [generation (Object.)]
|
||||||
{:state state
|
(swap! state assoc :generation generation :connected? false
|
||||||
:latest-price latest-price}))))
|
:last-message (System/currentTimeMillis))
|
||||||
|
(try
|
||||||
|
(let [ws (connect! client uri query-fn state latest-price generation)]
|
||||||
|
(swap! state assoc :ws ws)
|
||||||
|
(try
|
||||||
|
(while (and (:running? @state) (:connected? @state)
|
||||||
|
(< (- (System/currentTimeMillis) (:last-message @state)) stale-after-ms))
|
||||||
|
(Thread/sleep 1000))
|
||||||
|
(finally (.abort ^WebSocket ws))))
|
||||||
|
(catch InterruptedException e (throw e))
|
||||||
|
(catch Exception e (log/warn e "Binance connection failed; retrying"))
|
||||||
|
(finally (swap! state assoc :connected? false :generation nil :ws nil))))
|
||||||
|
(when (:running? @state) (Thread/sleep 3000)))
|
||||||
|
(catch InterruptedException _))))]
|
||||||
|
{:state state :latest-price latest-price :worker worker}))
|
||||||
|
|
||||||
(defmethod ig/halt-key! :ws/binance
|
(defmethod ig/halt-key! :ws/binance [_ {:keys [state worker]}]
|
||||||
[_ {:keys [state]}]
|
(swap! state assoc :running? false :connected? false :generation nil)
|
||||||
(log/info "Stopping Binance WebSocket listener")
|
(when-let [ws (:ws @state)] (.abort ^WebSocket ws))
|
||||||
(swap! state assoc :running? false)
|
(when worker (future-cancel worker)))
|
||||||
(when-let [ws (:ws @state)]
|
|
||||||
(try
|
|
||||||
(.sendClose ws WebSocket/NORMAL_CLOSURE "shutting down")
|
|
||||||
(catch Exception _))))
|
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
(ns pmagnus.btcdata.web.controllers.health-test
|
(ns pmagnus.btcdata.web.controllers.health-test
|
||||||
(:require
|
(:require [clojure.test :refer [deftest is]]
|
||||||
[clojure.test :refer [deftest is testing]]
|
[pmagnus.btcdata.web.controllers.health :as health]))
|
||||||
[pmagnus.btcdata.web.controllers.health :as health]))
|
|
||||||
|
(def disabled-stream {:state (atom {:enabled? false}) :latest-price (atom nil)})
|
||||||
|
|
||||||
(deftest healthcheck-is-process-only
|
(deftest healthcheck-is-process-only
|
||||||
(let [response (health/healthcheck! {})]
|
(is (= 200 (:status (health/healthcheck! {})))))
|
||||||
(is (= 200 (:status response)))
|
|
||||||
(is (= "up" (get-in response [:body :app :status])))))
|
|
||||||
|
|
||||||
(deftest readiness-check-tests-database
|
(deftest readiness-check-tests-database
|
||||||
(testing "ready when the database query succeeds"
|
(let [called (atom nil)
|
||||||
(let [called (atom nil)
|
query-fn (fn [query params] (reset! called [query params]))]
|
||||||
query-fn (fn [query params]
|
(is (= 200 (:status (health/readiness-check! query-fn disabled-stream {}))))
|
||||||
(reset! called [query params])
|
(is (= [:check-database {}] @called)))
|
||||||
{:healthy 1})
|
(is (= 503 (:status (health/readiness-check!
|
||||||
response (health/readiness-check! query-fn {})]
|
(fn [_ _] (throw (ex-info "database unavailable" {})))
|
||||||
(is (= 200 (:status response)))
|
disabled-stream {})))))
|
||||||
(is (= {:status "ready"} (:body response)))
|
|
||||||
(is (= [:check-database {}] @called))))
|
|
||||||
|
|
||||||
(testing "not ready when the database query fails"
|
(deftest stale-stream-is-not-ready
|
||||||
(let [query-fn (fn [_ _] (throw (ex-info "database unavailable" {})))
|
(is (= 503 (:status (health/readiness-check!
|
||||||
response (health/readiness-check! query-fn {})]
|
(fn [_ _] {})
|
||||||
(is (= 503 (:status response)))
|
{:state (atom {:enabled? true :connected? true})
|
||||||
(is (= {:status "not ready"} (:body response))))))
|
:latest-price (atom {:recorded-at java.time.Instant/EPOCH})}
|
||||||
|
{})))))
|
||||||
|
|||||||
@@ -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))))))))
|
||||||
Reference in New Issue
Block a user