Skip to content

Commit 463f5a8

Browse files
Merge pull request #87 from OpenVoxProject/feature/readiness-service
Add readiness coordination for systemd notifications
2 parents 534a712 + ba1c807 commit 463f5a8

6 files changed

Lines changed: 239 additions & 17 deletions

File tree

project.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
[org.openvoxproject/i18n ~i18n-version]]
9696

9797
:eastwood {:ignored-faults {:reflection {puppetlabs.trapperkeeper.logging [{:line 92}]
98-
puppetlabs.trapperkeeper.internal [{:line 177}]
98+
puppetlabs.trapperkeeper.internal [{:line 230}]
9999
puppetlabs.trapperkeeper.testutils.logging true
100100
puppetlabs.trapperkeeper.testutils.logging-test true
101101
puppetlabs.trapperkeeper.services.nrepl.nrepl-service-test true

src/puppetlabs/trapperkeeper/core.clj

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,19 @@
1313
(:import
1414
(clojure.lang ExceptionInfo)))
1515

16-
(def #^{:macro true
17-
:doc "An alias for the `puppetlabs.trapperkeeper.services/service` macro
18-
so that it is accessible from the core namespace along with the
19-
rest of the API."}
20-
service #'services/service)
21-
22-
(def #^{:macro true
23-
:doc "An alias for the `puppetlabs.trapperkeeper.services/defservice` macro
24-
so that it is accessible from the core namespace along with the
25-
rest of the API."}
26-
defservice #'services/defservice)
16+
(defmacro service
17+
"An alias for the `puppetlabs.trapperkeeper.services/service` macro
18+
so that it is accessible from the core namespace along with the
19+
rest of the API."
20+
[& forms]
21+
`(services/service ~@forms))
22+
23+
(defmacro defservice
24+
"An alias for the `puppetlabs.trapperkeeper.services/defservice` macro
25+
so that it is accessible from the core namespace along with the
26+
rest of the API."
27+
[svc-name & forms]
28+
`(services/defservice ~svc-name ~@forms))
2729

2830
(defn build-app
2931
"Given a list of services and a map of configuration data, build an instance

src/puppetlabs/trapperkeeper/internal.clj

Lines changed: 140 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
(:require [clojure.tools.logging :as log]
1212
[beckon]
13+
[clojure.set :as set]
1314
[plumbing.graph :as graph]
1415
[slingshot.slingshot :refer [throw+]]
1516
[puppetlabs.trapperkeeper.config :refer [config-service get-in-config]]
@@ -77,6 +78,22 @@
7778
(defn notice-service-reloading [] (maybe-notify-systemd "RELOADING=1\n"))
7879
(defn notice-service-stopping [] (maybe-notify-systemd "STOPPING=1\n"))
7980

81+
(defn maybe-send-ready-notice!
82+
[readiness-state]
83+
(let [send-notice? (atom false)]
84+
(swap! readiness-state
85+
(fn [{:keys [notifications-enabled? registered ready notice-sent?] :as state}]
86+
(if (and notifications-enabled?
87+
(not notice-sent?)
88+
(seq registered)
89+
(set/subset? registered ready))
90+
(do
91+
(reset! send-notice? true)
92+
(assoc state :notice-sent? true))
93+
state)))
94+
(when @send-notice?
95+
(notice-service-ready))))
96+
8097
;; This is (eww) a global variable that holds a reference to all of the running
8198
;; Trapperkeeper apps in the process. It can be used when connecting via nrepl
8299
;; to allow you to do useful things, and also may be used for other things
@@ -85,6 +102,42 @@
85102

86103
(def max-pending-lifecycle-events 5)
87104

105+
;; Debounce at the signal handler boundary to avoid back-to-back
106+
;; restart cycles that can interrupt in-flight agent requests.
107+
(def min-sighup-restart-interval-ms 500)
108+
(def last-sighup-restart-ms (atom nil))
109+
110+
(defn now-ms []
111+
(System/currentTimeMillis))
112+
113+
(defn should-handle-sighup-restart?
114+
[]
115+
(let [current-ms (now-ms)
116+
accepted? (atom false)
117+
previous-ms (atom nil)]
118+
(swap! last-sighup-restart-ms
119+
(fn [last-ms]
120+
(reset! previous-ms last-ms)
121+
(if (and (some? last-ms)
122+
(< (- current-ms last-ms) min-sighup-restart-interval-ms))
123+
last-ms
124+
(do
125+
(reset! accepted? true)
126+
current-ms))))
127+
(if @accepted?
128+
(log/debug (i18n/trs "Accepting SIGHUP restart at {0}; previous accepted restart was {1}"
129+
current-ms
130+
@previous-ms))
131+
(log/warn (i18n/trs "Ignoring duplicate SIGHUP restart at {0}; previous accepted restart was {1}; minimum interval is {2} ms"
132+
current-ms
133+
@previous-ms
134+
min-sighup-restart-interval-ms)))
135+
@accepted?))
136+
137+
(defn app-log-id
138+
[app]
139+
(str "app=0x" (Integer/toHexString (System/identityHashCode app))))
140+
88141
(defn service-graph?
89142
"Predicate that tests whether or not the argument is a valid trapperkeeper
90143
service graph."
@@ -351,6 +404,7 @@
351404
[apps]
352405
(log/info (i18n/trs "SIGHUP handler restarting TK apps."))
353406
(doseq [app apps]
407+
(log/info (i18n/trs "Queueing SIGHUP restart for TK {0}" (app-log-id app)))
354408
(let [{:keys [lifecycle-channel]} @(a/app-context app)
355409
restart-fn #(a/restart app)]
356410
(when-not (async/offer! lifecycle-channel
@@ -359,6 +413,14 @@
359413
(log/warn (i18n/trs "Ignoring new SIGHUP restart requests; too many requests queued ({0})"
360414
max-pending-lifecycle-events))))))
361415

416+
(defn maybe-restart-tk-apps
417+
"Restart all TK apps unless this SIGHUP request is a rapid duplicate."
418+
[apps]
419+
(if (should-handle-sighup-restart?)
420+
(restart-tk-apps apps)
421+
(log/warn (i18n/trs "Ignoring duplicate SIGHUP restart request received within {0} ms"
422+
min-sighup-restart-interval-ms))))
423+
362424
(defn register-sighup-handler
363425
"Register a handler for SIGHUP that restarts all trapperkeeper apps. The
364426
default handler terminates the process, so we always overwrite that. This
@@ -367,7 +429,7 @@
367429
(register-sighup-handler @tk-apps))
368430
([apps]
369431
(log/debug (i18n/trs "Registering SIGHUP handler for restarting TK apps"))
370-
(reset! (beckon/signal-atom "HUP") #{(partial restart-tk-apps apps)})))
432+
(reset! (beckon/signal-atom "HUP") #{(partial maybe-restart-tk-apps apps)})))
371433

372434
;;;; Application Shutdown Support
373435
;;;;
@@ -469,6 +531,20 @@
469531
"Higher-order function to execute application logic and trigger shutdown in
470532
the event of an exception"))
471533

534+
(defprotocol ReadinessService
535+
(register-ready! [this service-id]
536+
"Register a service that will signal readiness explicitly.")
537+
(signal-ready! [this service-id]
538+
"Mark a registered service as ready.")
539+
(readiness-coordinated? [this]
540+
"Returns true if any services have registered explicit readiness coordination.")
541+
(enable-ready-notifications! [this]
542+
"Allow readiness notifications to be emitted once all registered services are ready.")
543+
(reset-readiness! [this]
544+
"Reset readiness tracking for a boot or restart cycle.")
545+
(readiness-state [this]
546+
"Return the current readiness tracking state."))
547+
472548
(schema/defn shutdown-service
473549
"Provides various functions for triggering application shutdown programatically.
474550
Primarily intended to serve application services, though TrapperKeeper also uses
@@ -497,6 +573,60 @@
497573
(shutdown-on-error [this svc-id f] (shutdown-on-error* shutdown-reason-promise app-context svc-id f))
498574
(shutdown-on-error [this svc-id f on-error] (shutdown-on-error* shutdown-reason-promise app-context svc-id f on-error))))
499575

576+
(schema/defn readiness-service :- (schema/protocol s/ServiceDefinition)
577+
"Provides explicit readiness coordination for services that need to delay
578+
systemd READY=1 until after their own startup work is complete. Services that
579+
participate should call `register-ready!` before app startup completes and
580+
`signal-ready!` when they are actually ready to serve traffic."
581+
[]
582+
(let [state (atom {:notifications-enabled? false
583+
:registered #{}
584+
:ready #{}
585+
:notice-sent? false})]
586+
(s/service ReadinessService
587+
[]
588+
(register-ready! [_ service-id]
589+
(schema/validate schema/Keyword service-id)
590+
(swap! state update :registered conj service-id)
591+
(maybe-send-ready-notice! state)
592+
nil)
593+
(signal-ready! [_ service-id]
594+
(schema/validate schema/Keyword service-id)
595+
(swap! state update :ready conj service-id)
596+
(maybe-send-ready-notice! state)
597+
nil)
598+
(readiness-coordinated? [_]
599+
(boolean (seq (:registered @state))))
600+
(enable-ready-notifications! [_]
601+
(swap! state assoc :notifications-enabled? true)
602+
(maybe-send-ready-notice! state)
603+
nil)
604+
(reset-readiness! [_]
605+
(reset! state {:notifications-enabled? false
606+
:registered #{}
607+
:ready #{}
608+
:notice-sent? false})
609+
nil)
610+
(readiness-state [_]
611+
@state))))
612+
613+
(defn reset-readiness-tracking!
614+
[services-by-id]
615+
(when-let [readiness-service (services-by-id :ReadinessService)]
616+
(reset-readiness! readiness-service)))
617+
618+
(defn enable-ready-notifications-for-app!
619+
[services-by-id]
620+
(when-let [readiness-service (services-by-id :ReadinessService)]
621+
(enable-ready-notifications! readiness-service)))
622+
623+
(defn notice-service-ready-if-uncoordinated!
624+
[services-by-id]
625+
(if-let [readiness-service (services-by-id :ReadinessService)]
626+
(when-not (readiness-coordinated? readiness-service)
627+
(notice-service-ready))
628+
(notice-service-ready)))
629+
500630
(schema/defn ^:always-validate shutdown! :- [Throwable]
501631
"Perform shutdown calling the `stop` lifecycle function on each service,
502632
in reverse order (to account for dependency relationships).
@@ -633,6 +763,7 @@
633763
service-refs (atom {})
634764
services (conj services
635765
(config-service config-data-fn)
766+
(readiness-service)
636767
(initialize-shutdown-service! app-context
637768
shutdown-reason-promise))
638769
service-map (apply merge (map s/service-map services))
@@ -660,11 +791,13 @@
660791
(a/check-for-errors! [this] (throw-app-error-if-exists!
661792
this))
662793
(a/init [this]
794+
(reset-readiness-tracking! services-by-id)
663795
(run-lifecycle-fns app-context s/init "init" ordered-services)
664796
this)
665797
(a/start [this]
666798
(run-lifecycle-fns app-context s/start "start" ordered-services)
667-
(notice-service-ready)
799+
(enable-ready-notifications-for-app! services-by-id)
800+
(notice-service-ready-if-uncoordinated! services-by-id)
668801
(inc-restart-counter! this)
669802
this)
670803
(a/stop [this]
@@ -679,12 +812,16 @@
679812
this)))
680813
(a/restart [this]
681814
(try
815+
(log/info (i18n/trs "Starting restart for TK {0}" (app-log-id this)))
682816
(notice-service-reloading)
683817
(run-lifecycle-fns app-context s/stop "stop" (reverse ordered-services))
684818
(doseq [svc-id (keys services-by-id)] (swap! app-context assoc-in [:service-contexts svc-id] {}))
819+
(reset-readiness-tracking! services-by-id)
685820
(run-lifecycle-fns app-context s/init "init" ordered-services)
686821
(run-lifecycle-fns app-context s/start "start" ordered-services)
687-
(notice-service-ready)
822+
(enable-ready-notifications-for-app! services-by-id)
823+
(notice-service-ready-if-uncoordinated! services-by-id)
824+
(log/info (i18n/trs "Finished restart for TK {0}" (app-log-id this)))
688825
(inc-restart-counter! this)
689826
this
690827
(catch Throwable t

src/puppetlabs/trapperkeeper/services.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
(get-services [this#]
104104
(-> ~'@tk-app-context
105105
:services-by-id
106-
(dissoc :ConfigService :ShutdownService)
106+
(dissoc :ConfigService :ShutdownService :ReadinessService)
107107
vals))
108108
(service-symbol [this#] '~service-sym)
109109
(service-included? [this# service-id#]

test/puppetlabs/trapperkeeper/internal_test.clj

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,23 @@
108108
(tk-app/stop app)
109109
;; and make sure that we got one last :stop
110110
(is (= (conj expected-lifecycle-events :stop)
111-
@lifecycle-events)))))
111+
@lifecycle-events)))))
112+
113+
(deftest test-sighup-restart-debounce
114+
(let [restart-calls (atom 0)]
115+
(with-redefs [internal/now-ms (let [times (atom [1000 1100 2000])]
116+
(fn []
117+
(let [t (first @times)]
118+
(swap! times rest)
119+
t)))
120+
internal/restart-tk-apps (fn [_apps]
121+
(swap! restart-calls inc))
122+
internal/last-sighup-restart-ms (atom nil)]
123+
(internal/maybe-restart-tk-apps [:app])
124+
(logging/with-test-logging
125+
(internal/maybe-restart-tk-apps [:app])
126+
(is (logged? "Ignoring duplicate SIGHUP restart request received within 500 ms"
127+
:warn)
128+
"Missing expected warning log for duplicate SIGHUP"))
129+
(internal/maybe-restart-tk-apps [:app])
130+
(is (= 2 @restart-calls)))))

test/puppetlabs/trapperkeeper/services_test.clj

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@
5555
(defprotocol Service3
5656
(service3-fn [this]))
5757

58+
(defprotocol CoordinatedReadyService
59+
(mark-ready [this])
60+
(current-readiness-state [this]))
61+
5862
(deftest test-services-not-required
5963
(testing "services are not required to define lifecycle functions"
6064
(let [service1 (service Service1
@@ -520,6 +524,66 @@
520524
(is (= #{:EmptyService :HelloService}
521525
(set (map svcs/service-id all-services)))))))))))
522526

527+
(deftest readiness-service-falls-back-to-central-ready-notice
528+
(let [ready-notices (atom 0)
529+
service1 (service Service1
530+
[]
531+
(service1-fn [_] "hi"))]
532+
(with-redefs [internal/notice-service-ready #(swap! ready-notices inc)]
533+
(with-app-with-empty-config app [service1]
534+
(is (= 1 @ready-notices))))))
535+
536+
(deftest readiness-service-defers-ready-notice-until-signaled
537+
(let [ready-notices (atom 0)
538+
coordinated-service
539+
(service CoordinatedReadyService
540+
[[:ReadinessService register-ready! signal-ready! readiness-state]]
541+
(init [_ context]
542+
(register-ready! :CoordinatedReadyService)
543+
context)
544+
(start [_ context]
545+
context)
546+
(mark-ready [_]
547+
(signal-ready! :CoordinatedReadyService))
548+
(current-readiness-state [_]
549+
(readiness-state)))]
550+
(with-redefs [internal/notice-service-ready #(swap! ready-notices inc)]
551+
(with-app-with-empty-config app [coordinated-service]
552+
(let [service (app/get-service app :CoordinatedReadyService)]
553+
(is (zero? @ready-notices))
554+
(is (= #{:CoordinatedReadyService}
555+
(get-in (current-readiness-state service) [:registered])))
556+
(mark-ready service)
557+
(is (= 1 @ready-notices))
558+
(mark-ready service)
559+
(is (= 1 @ready-notices)))))))
560+
561+
(deftest readiness-service-resets-across-restart
562+
(let [ready-notices (atom 0)
563+
coordinated-service
564+
(service CoordinatedReadyService
565+
[[:ReadinessService register-ready! signal-ready! readiness-state]]
566+
(init [_ context]
567+
(register-ready! :CoordinatedReadyService)
568+
context)
569+
(start [_ context]
570+
context)
571+
(mark-ready [_]
572+
(signal-ready! :CoordinatedReadyService))
573+
(current-readiness-state [_]
574+
(readiness-state)))]
575+
(with-redefs [internal/notice-service-ready #(swap! ready-notices inc)]
576+
(with-app-with-empty-config app [coordinated-service]
577+
(let [service (app/get-service app :CoordinatedReadyService)]
578+
(mark-ready service)
579+
(is (= 1 @ready-notices))
580+
(app/restart app)
581+
(is (= 1 @ready-notices))
582+
(is (= #{:CoordinatedReadyService}
583+
(get-in (current-readiness-state service) [:registered])))
584+
(mark-ready service)
585+
(is (= 2 @ready-notices)))))))
586+
523587
(deftest minimal-services-test
524588
(testing "minimal services can be defined without a protocol"
525589
(let [call-seq (atom [])

0 commit comments

Comments
 (0)