oakes / odoyle-rules

A rules engine for Clojure(Script)
The Unlicense
530 stars 20 forks source link

mutating session outside of a rule? #29

Closed nivekuil closed 9 months ago

nivekuil commented 9 months ago

Hi, so with the new dynamic rule stuff I've been building a framework that combines odoyle and asami for a reactive, forward and backward-chaining datalog store designed to serve both web apps and games. In combination with Electric clojure this augments odoyle with ergonomic multi-cardinality joins, cleanup handlers (== truth maintenance), maybe even distributed storage later -- so far it's been looking great.

One of the challenges I've run into is mixing odoyle managed mutations like o/insert!, which operate on a *mutable-session*, and mutations from outside a rule, which would swap! the session atom. I think the way odoyle does it creates a race condition: fire-rules basically takes ownership of the session with interior mutability, and any changes to the session that happen while fire-rules is going off they will be lost when the session atom is reset to the result of fire-rules.

This can happen with relatively plain odoyle use. Take for example my recent game jam submission: when you start dragging the launcher it registers an pointerup event handler which inserts the rules to launch the ball. It's hard to trigger in the published demo but as I've been pushing it it happens relatively often that the pointerup event gets essentially skipped because it's processed in the middle of fire-rules.

Thinking about it a bit more I think I should just not use fire-rules at all, use swap! instead of o/reset! everywhere and write my own impure fire-rules!.

oakes commented 9 months ago

If you are doing a swap! on the session atom from inside a rule, that sounds like a problem. Mutating an atom from inside another swap! fn in general is a bad idea, whether you're using odoyle or not. I'm not sure why this would happen with browser events like pointerup; you can mutate an atom from various browser events and trigger your rules just fine. It sounds like one or more of your rules are ultimately trying to mutate an atom, though. Is there a reason they need to do this?

nivekuil commented 9 months ago

Is there a reason they need to do this?

Bridging odoyle to electric means that we lose dynamic binding conveyance from clojure to electric, it's got its own semantics.

My solution has been to make this change to o/fire-rules

+   (let [session (cond-> session (:transform opts) ((:transform opts)))
          then-queue (:then-queue session)
          then-finally-queue (:then-finally-queue session)]

Then in my code I can queue up work instead of touching *mutable-session*:

(def fact-queue (js/Array.))
(defn add-work! [f]
  (.push fact-queue f))
(defn fire-rules [s]
  (o/fire-rules s {:transform (fn [session]
                                (let [s (reduce (fn[s f](f s)) session fact-queue)]
                                  (set! fact-queue (js/Array.))
                                  s))}))

(add-work! (fn [s](o/insert s e a v))) 

I think something like this is also necessary for a multi threaded fire-rules, though I'm only concerned with cljs right now.

I'm not sure why this would happen with browser events like pointerup

this was probably a red herring, haven't seen an issue with this since I made this change