mount icon indicating copy to clipboard operation
mount copied to clipboard

lifecycle hook API

Open tolitius opened this issue 8 years ago • 6 comments

Would be niice to have open around hooks into lifecycle methods. This would address "cross cutting concerns":

  • logging
  • exception handling
  • security
  • performace
  • transaction management
  • validation
  • etc..

where all these functions can be custom made, and shared. this could be a point of reference to bounce off of.

tolitius avatar Nov 23 '15 22:11 tolitius

In addition to component specific lifecycle hooks, it would be great to have something like an :on-system-up / :on-system-down hooks as well.

A good example of it is an event bus that should not send any messages until all the subscribers have started.

tolitius avatar Jan 07 '16 04:01 tolitius

So having used Mount for the last year, I have to say this is my biggest hack (with relation to Mount specifically 😉 ):

The Code

(defonce ^:private all-mounts (atom []))
(defonce ^:private started-mounts (atom #{}))
(defonce ^:private lock (Object.))

(defn stop
  "Tries to stop all mountables. Will not rethrow exceptions encountered."
  []
  ;; modelled after mount/stop
  (locking lock
    (let [states (mount/find-all-states)]
      (dorun (map #'mount/unsub states))
      (#'mount/bring states
        (fn [state {:keys [stop status] :as current} done]
          (when (@started-mounts state)
            (try
              (log/debug state "attempting to stop...")
              (#'mount/down state current done)
              (log/debug state "stopped")
              (catch Throwable t
                (log/error t "Caught throwable on stop. Mount unable to proceed for " state))))
          (swap! started-mounts disj state))
        <)
      (dorun (map #'mount/rollback! states))
      (reset! all-mounts []))))

(defn start
  []
  (.addShutdownHook (Runtime/getRuntime) (Thread. stop))
  (locking lock
    (try
      ;; modelled after mount/start
      (#'mount/bring (#'mount/all-without-subs)
        (fn [state & args]
          (when-not (@started-mounts state)
            (log/debug state "attempting to start...")
            (apply #'mount/up state args)
            (log/debug state "started."))
          (swap! started-mounts conj state))
        <)
      (reset! all-mounts (mount/find-all-states))
      (catch Throwable t
        (log/error t "Caught throwable on start. Mount unable to proceed.")
        (throw t)))))

I reallllllly hate this chunk of code. I am committing far too much innards-mangling in Mount to get this to work. Though, in saying that, I realize I'm not the only one who's doing such things.

Another atrocity committed in the name of Mount is this beauty:

(defn- ->status-fn
  [ns-str]
  (let [[_ ns-cleaned] (re-matches #"#'([\w-\.]+)/.*"
                                   ns-str)
        status-fn-str (str ns-cleaned "/status")
        status-fn (resolve (symbol status-fn-str))]
    (when-not status-fn
      (throw (ex-info "status fn not found"
                      {::fn status-fn-str})))
    status-fn))

(defn- mounted-status
  [ns-str]
  (let [reason (try
                 ;; TODO: add in thread killing timeout
                 ((->status-fn ns-str))
                 (catch Exception e
                   (when (-> e
                             ex-data
                             ::fn)
                     (log/error e)
                     (throw e))
                   e))]
    {::mounted ns-str
     ::started? (boolean (@started-mounts ns-str))
     ::reason (pr-str reason)
     ::healthy? (and (not (instance? Exception reason))
                     (boolean reason))}))

(defn- boolean? [x]
  (instance? Boolean x))

(defn status
  []
  (mapv mounted-status
        @all-mounts))

What that's doing

So the first example above is adding some generic error handling around spinning mounts up (similar to this issue). This has been invaluable for figuring out what part of our systems is upset with life and causing a fuss. It also setups the shutdown hook etc.

The second has been useful for a health check on the system. Every mount defined in our setup has to have a function in the same ns called status. We grab all the status fns and then run them to see whether that particular mount is happy.

A proposal

I think a really simple way to implement lifecycle style fns is twofold:

  • middleware similar to https://github.com/http-kit/http-kit or https://github.com/dakrone/clj-http
    • the start and stop fns could have similar fns called start-with-middleware and stop-with-middleware or macros which set the middleware which is going to be used for the fns being run.
  • the ability to add keywords to the defstate macro and have functions of those names be accessible by a (defn perform [fn-key] ...) which could run (defn perform [:start] ...) etc.

Happy to discuss approach more and would also be happy to fork and whip up what I'm thinking somewhere public to be able to discuss!

(also, huge thanks for Mount, really loving working with it. I've seen a marked drop in the number of lines of code necessary to build out a service and also seeing a really nice side effect of smaller more targeted services)

AlexanderMann avatar Nov 07 '17 16:11 AlexanderMann

thanks for the feedback and a detailed writeup.

I agree, these are both good and useful features. Being working on libraries I learn to prefer removing things rather than adding :)

Hence I believe both could be achieved outside of mount with something like mount-up.

Inspired by your writeup I just added a :wrap-in custom function to mount-up which works as an AOP around advice and is able to have a control over start and stop calls:

(mu/on-up :guard (mu/try-catch log-exception) :wrap-in)
boot.user=> (mount/start)
INFO  mount-up.core - >> starting.. #'boot.user/server
INFO  mount-up.core - >> starting.. #'boot.user/db
ERROR boot.user - could not start [#'boot.user/db] due to "Divide by zero"
INFO  mount-up.core - >> starting.. #'boot.user/pi
{:started ["#'boot.user/server" "#'boot.user/pi"]}

I believe #'app.ns/status could also be defined / initialized with these custom functions. Although I admit to come up with a clean and simple solution, a /status bit needs more thinking, since one namespace could have multiple mount states defined + it is a little too magical to know that if you define a status in a namespace it is going to be picked up by something.

I'd rather be more explicit: i.e. have a map defined, could be in an arbitrary named (i.e. #'app.healthcheck) namespace and would map states to health check functions. But again.. it does need more thinking.

tolitius avatar Nov 08 '17 06:11 tolitius

Awesome, those are great references! The only call out I was going to give was what you wrote up about the :wrap-in (ie, wrapped middleware logic vs interceptor logic).

The explicit write up is nice, however the one thing we've found is that if you're using the health check, it will not even let the app start up because it couldn't find the status fn. Mind you, this is something we could easily bake in via other means.

I'll take a more thorough look at mount-up this weekend and try to get a good sense of whether status belongs in there etc.

AlexanderMann avatar Nov 10 '17 21:11 AlexanderMann

So in looking at mount-up I do think that handles my first use case pretty well. I'll have to play with it a bit more but no issues thus far (I'll reshare my above code rewritten when I get the chance).

AlexanderMann avatar Nov 14 '17 16:11 AlexanderMann

sounds good. let me know how it goes.

tolitius avatar Nov 15 '17 16:11 tolitius