missionary

euccastro 2025-03-01T03:00:36.963579Z

This week I've used (very basic) missionary to implement a stress test harness for our web server, reproducing certain "abuse" scenarios to validate hypothesis about the possible causes for problems we spotted in prod. I was impressed at how snappy cancellation was, and at being able to keep this REPL alive for three days as it killed my web server once and again. One of the conclusions of these experiments is that we need to improve at canceling long-running requests on this ring-jetty-backed server. Essentially, our problems boiled down to the kind of things that missionary takes care of best: cancellation and cleanup of worker processes.

👏 3
euccastro 2025-03-01T13:06:30.447279Z

In related matters, I was going to try and wrap https://stackoverflow.com/a/296435 in a missionary task, which then I could wrap with e/Task in Electric. Then I thought that someone must have done that already. Anyone?

euccastro 2025-03-04T15:46:14.753689Z

I don't know how I convinced myself that this version works, but you need to remove all the locking until a better solution is found. The locking tried to avoid a race condition preventing the query from being cancelled if it starts executing between the .cancel (which does nothing if the query has not started yet) and the .close (which does nothing if the query has started already). But .cancel cannot interrupt the execute! if they are mutually excluded.

leonoel 2025-03-07T11:05:07.696729Z

I would rather check for thread interruption before running the statement, and use a separate task to cancel the statement. Example (untested) :

(defn execute-task
  "`conn` is a next.jdbc Connection (NB: *not* a data source)
  `sql` is the vector representing the query (e.g., [\"SELECT 1\"]).
  `opts` are any other options you would supply to jdbc/execute!"
  [conn sql & opts]
  (m/sp (let [stmt (apply jdbc/prepare conn sql opts)]
          (m/? (m/race
                 (m/sp (try (m/? m/never) (finally (.cancel stmt))))
                 (m/via m/blk
                   (try
                     (m/!)
                     (jdbc/execute! stmt)

                     ;; XXX: how to tell a "cancelled" exception from others varies
                     ;; across JDBC drivers, YMMV.

                     (catch MySQLStatementCancelledException _
                       (try
                         ;; Only rollback if we're not in auto-commit mode
                         (when (not (.getAutoCommit conn))
                           (.rollback conn))
                         (catch SQLException _
                           ;; possibly log or measure how often you get this?
                           ))

                       (throw (ex-info "SQL query closed before executing" {})))
                     (finally
                       (.close stmt)))))))))

euccastro 2025-03-07T11:19:56.103439Z

Amazing! I finally had this working acceptably and I meant to update this thread, but I'll try that first. Adding (m/sp (try (m/? m/never) (finally (.cancel stmt)))) to my bag of tricks, thanks!

euccastro 2025-03-08T15:49:15.780469Z

That works, except at least the m/via task needs to be wrapped in this m/attempt/`m/absolve` pattern around the m/race, like the implementation of m/timeout does, since that task can throw all kinds of exceptions that we want to propagate to the caller (e.g. SQL syntax errors, constraint violations, etc.). If left unwrapped, any exception would freeze the race forever

euccastro 2025-03-08T15:49:36.290089Z

(or until cancelled from outside)

euccastro 2025-03-08T16:09:24.533899Z

I'm using this pattern in a different use case where I want a timeout that is remotely started by signaling a dfv. I wonder whether a version of m/race that returns the first successful or failing result would be useful, both as a utility, and to call the user's attention on that nuance of how m/race itself works, which is easy to miss or forget.

(defn duel
  "Like `race`, but ends as soon as any of `task` succeeds *or fails*, yielding the same
  success or failure result."
  [& tasks]
  (->> tasks
       (map attempt)
       (apply race)
       absolve))

euccastro 2025-03-08T16:11:33.726659Z

The name "duel" is because in old sword/muskeet duels the point was not so much to "win" (preferrable as that was to the alternative) as to back one's honor with one's life

leonoel 2025-03-08T20:29:04.552029Z

oh you're right, I keep forgetting about this problem

leonoel 2025-03-08T20:29:29.212719Z

I've also struggled to find a good name for this operator

leonoel 2025-03-10T19:42:26.880979Z

Could you elaborate the duel metaphor ? I understand that a duel terminates when any principal withdraws, but how do task success/failure relate to duel outcomes ? Is a first wound considered success ?

euccastro 2025-03-11T23:34:43.418799Z

I actually asked some LLM and it suggested sports/fighting metaphors like "sudden death" and "first blood". I adopted the name duel for my own utility library on the basis that it is short and memorable, implies competition, and a race goes on if some participant withdraws early, but a duel is done as soon as the first contender is done one way or the other. It's far from a perfect metaphor or an obvious choice. But I think some of the shortcomings of the name are alleviated if the function is described in contrast with race.

euccastro 2025-03-12T00:02:22.172879Z

DeepSeek suggested rush, which I like too, because it doesn't have the somewhat misleading connotation of two participants like duel does, and in contrast with race, rush is more about finishing quickly than about success. To me that word brings to mind higher risk of failure from focusing on speed.

euccastro 2025-03-01T22:58:51.811309Z

euccastro 2025-03-01T23:37:21.448999Z

Fixed a race condition: query would not be cancelled if we tried to do so before it had a chance to execute.

euccastro 2025-03-01T23:40:12.459939Z

I forgot to throw the ex-infos.

awb99 2025-03-01T19:37:49.021209Z

When I define a m/ap flow using loop/recur, it is possible that I can notify that the flow has ended by simply not recurring anymore. I tried the same with m/?> by returning (reduced data) or by throwing missionary Cancelled, but both approaches do not work. Attached is a snippet that illustrates my issue. c-f should just print the first 4 items and then the reducer task should be finished normally.

awb99 2025-03-04T16:37:02.140879Z

very interesting @xifi So the ap flows are not consumed, unless requested downstream.

xificurC 2025-03-04T17:16:46.254859Z

yes. It's supervision, eduction supervises ap and since it knows it's done it cleans up its children

xificurC 2025-03-01T20:14:51.818919Z

(m/eduction (take 4) a-f)

awb99 2025-03-01T23:17:18.457899Z

Thanks @xifi this would work if I just want to take a Fixed number of items. But I would like to do this inside the ap where I use the forked operator, as I then can make it dependen on the state I have accumulated. It is also a good question because seed generated flows can end and flows generated with forks should also have the possibillity to end.

xificurC 2025-03-01T23:46:53.118419Z

I see, thanks for the clarification. The pattern for that would be to return a sentinel value and check for it. E.g. return ::done and then (m/eduction (take-while (complement #{done}))`

awb99 2025-03-02T01:08:04.603739Z

yes, but then the fork of the ap process would still be running, and would still keep sending done in this case.

xificurC 2025-03-02T09:04:10.065279Z

No it wouldn't, as eduction terminates it will terminate its child

xificurC 2025-03-02T09:04:28.322839Z

Try it out in the REPL