Restart btcdata after losing its writer session #3

Merged
magnus merged 1 commits from fix/restart-closed-writer-session into master 2026-10-09 13:00:22 +00:00
6 changed files with 67 additions and 9 deletions
Showing only changes of commit 91f0367337 - Show all commits
+24
View File
@@ -1,5 +1,29 @@
# btcdata Kubernetes deployment plan
## 2026-10-09 incident: closed writer session never restarted
The citadel pod was Running but not Ready, and both btcprice pods were unready.
All writer queries failed with `Connection is closed`. Readiness correctly
returned 503, but `/api/health` always returned 200, so Kubernetes did not restart
the process. The initial connection-loss cause was not established from the
available logs. Fenced connections deliberately never reconnect in place.
Recovery deleted only the failed pod (normal graceful deletion); its Deployment
recreated it, and btcdata and both btcprice pods became Ready. No lock was
bypassed or database backend terminated for this incident.
The source fix makes `/api/health` return 503 when the writer session is closed
(or absent). This checks local JDBC state only: no SQL, external APIs, or Binance
freshness are used in liveness. Existing probes will then restart an irrecoverable
writer after three failed checks. Readiness retains database/freshness checks.
Unit tests passed: 21 tests, 63 assertions. PostgreSQL integration tests were
not run because the local Docker daemon was unavailable.
The recovered pod still runs `main-20260930155338`, not the new source changes.
Flux apps reconciliation is blocked on infrastructure health checks for image
repositories; building/publishing and a successful Flux rollout remain necessary
to activate the liveness and keepalive source fixes.
## 2026-10-07 incident: orphaned writer session
The `sejs/btcdata` pod on citadel entered CrashLoopBackOff with
@@ -2,15 +2,21 @@
(:require
[clojure.tools.logging :as log]
[ring.util.http-response :as response]
[pmagnus.btcdata.ws.binance :as binance])
[pmagnus.btcdata.ws.binance :as binance]
[pmagnus.btcdata.writer :as writer])
(:import
[java.lang.management ManagementFactory]))
(defn healthcheck! [_req]
(response/ok
{:time (str (java.time.Instant/now))
:up-time (.. ManagementFactory getRuntimeMXBean getUptime)
:app {:status "up"}}))
(defn healthcheck!
"No remote dependency checks. A permanently closed writer session needs restart."
[query-fn _req]
(if (writer/session-live? query-fn)
(response/ok
{:time (str (java.time.Instant/now))
:up-time (.. ManagementFactory getRuntimeMXBean getUptime)
:app {:status "up"}})
(response/service-unavailable {:status "restart required"
:reason "writer session closed"})))
(defn readiness-check!
"Require the lock-owning database session and a fresh upstream stream."
+1 -1
View File
@@ -180,7 +180,7 @@
:swagger {:info {:title "btcdata API"}}
:handler (swagger/create-swagger-handler)}}]
["/health"
{:get health/healthcheck!}]
{:get (fn [req] (health/healthcheck! (:query-fn opts) req))}]
["/ready"
{:get (fn [req] (health/readiness-check! (:query-fn opts) (:binance opts) req))}]
["/price/ws"
+9
View File
@@ -16,6 +16,15 @@
"SET tcp_user_timeout = 90000"]]
(jdbc/execute-one! connection [sql])))
(defn session-live?
"Local-only liveness check; a closed fenced session can never recover in place."
[query-fn]
(if-let [connection (:connection (meta query-fn))]
(try
(not (.isClosed ^java.sql.Connection connection))
(catch Exception _ false))
false))
(defn guarded-query [connection query-fn]
(fn [query params]
(locking connection
@@ -4,8 +4,18 @@
(def disabled-stream {:state (atom {:enabled? false}) :latest-price (atom nil)})
(deftest healthcheck-is-process-only
(is (= 200 (:status (health/healthcheck! {})))))
(deftest liveness-check-is-local-and-detects-lost-writer
(let [closed? (atom false)
connection (proxy [java.sql.Connection] []
(isClosed [] @closed?))
query-fn (with-meta (fn [& _] (throw (Exception. "Must not query database")))
{:connection connection})]
(is (= 200 (:status (health/healthcheck! query-fn {}))))
(reset! closed? true)
(is (= 503 (:status (health/healthcheck! query-fn {}))))
(is (= "writer session closed"
(get-in (health/healthcheck! query-fn {}) [:body :reason]))))
(is (= 503 (:status (health/healthcheck! (fn [& _]) {})))))
(deftest readiness-check-tests-database
(let [called (atom nil)
+9
View File
@@ -13,6 +13,13 @@
(is (= :result (query :insert-price {:price 1})))
(is (= [connection :insert-price {:price 1}] @seen))))
(deftest session-liveness-fails-closed
(is (false? (writer/session-live? (fn [& _]))))
(let [connection (proxy [java.sql.Connection] []
(isClosed [] (throw (java.sql.SQLException. "broken"))))]
(is (false? (writer/session-live? (with-meta (fn [& _])
{:connection connection}))))))
(deftest server-session-has-bounded-dead-peer-detection
(let [connection (Object.)
calls (atom [])]
@@ -44,7 +51,9 @@
(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.
(is (writer/session-live? first-writer))
(.close (:connection (meta first-writer)))
(is (false? (writer/session-live? first-writer)))
(is (thrown? Exception (first-writer :check {})))
(let [replacement (ig/init-key :writer/query-fn opts)]
(try