diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2d1533..6b40ee5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ This is a history of changes to gateless/clara-rules. +# 1.6.6 + +### Added +- **Query-only sessions**: New `:query-only?` option for deserializing sessions that only need to execute queries. Query-only sessions are a more aggressive optimization than read-only sessions (`:read-only?`), with smaller memory footprint and serialization size. Read-only sessions support both queries and inspection operations, while query-only sessions support queries only. Use query-only when you need the smallest possible session for query execution. + # 1.6.5 * remove generated bindings from clara.tools.inspect/inspect-facts bindings results diff --git a/pom.xml b/pom.xml index cf4b8f5d..15e5b2e6 100644 --- a/pom.xml +++ b/pom.xml @@ -5,9 +5,9 @@ com.github.gateless clara-rules clara-rules - 1.6.5 + 1.6.6 - 1.6.5 + 1.6.6 https://github.com/gateless/clara-rules scm:git:git://github.com/gateless/clara-rules.git scm:git:ssh://git@github.com/gateless/clara-rules.git diff --git a/src/main/clojure/clara/rules/compiler.clj b/src/main/clojure/clara/rules/compiler.clj index 84e5eb89..f3a5d9bf 100644 --- a/src/main/clojure/clara/rules/compiler.clj +++ b/src/main/clojure/clara/rules/compiler.clj @@ -172,7 +172,9 @@ (boolean (or (#{'= '== 'clojure.core/= 'clojure.core/==} op) (#{'clojure.core/= 'clojure.core/==} (qualify-when-sym op)))))) -(def ^:private read-only-expr (constantly true)) +(defn read-only-expr + [& _] + true) (def ^:dynamic *compile-ctx* nil) diff --git a/src/main/clojure/clara/rules/durability.clj b/src/main/clojure/clara/rules/durability.clj index bfa66ba3..f0ae475b 100644 --- a/src/main/clojure/clara/rules/durability.clj +++ b/src/main/clojure/clara/rules/durability.clj @@ -21,6 +21,12 @@ ;;;; Rulebase serialization helpers. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def ^:internal ^:dynamic *read-only* + "Indicates whether the rulebase or session is being deserialized in read-only mode. + This is useful for durability implementations to know whether to restore nodes + that would only be used for rule firing (which is disabled in read-only mode)." + false) + (def ^:internal ^:dynamic *node-id->node-cache* "Useful for caching rulebase network nodes by id during serialization and deserialization to avoid creating multiple object instances for the same node." @@ -488,7 +494,7 @@ Note! Currently this only supports the clara.rules.memory.PersistentLocalMemory implementation of memory." ([rulebase opts] - (let [{:keys [listeners transport read-only?]} opts + (let [{:keys [listeners transport read-only? query-only?]} opts memory (eng/local-memory rulebase (clara.rules.engine.LocalTransport.) (:activation-group-sort-fn rulebase) @@ -498,12 +504,18 @@ :transport (or transport (clara.rules.engine.LocalTransport.)) :listeners (or listeners []) :get-alphas-fn (:get-alphas-fn rulebase)}] - (if read-only? + (cond + query-only? + (eng/assemble-query-only components) + + read-only? (eng/assemble-read-only components) + + :else (eng/assemble components)))) ([rulebase memory opts] - (let [{:keys [listeners transport read-only?]} opts + (let [{:keys [listeners transport read-only? query-only?]} opts memory (assoc memory :rulebase rulebase :activation-group-sort-fn (:activation-group-sort-fn rulebase) @@ -513,8 +525,14 @@ :transport (or transport (clara.rules.engine.LocalTransport.)) :listeners (or listeners []) :get-alphas-fn (:get-alphas-fn rulebase)}] - (if read-only? + (cond + query-only? + (eng/assemble-query-only components) + + read-only? (eng/assemble-read-only components) + + :else (eng/assemble components))))) (defn rulebase->rulebase-with-opts @@ -527,6 +545,30 @@ :activation-group-fn (eng/options->activation-group-fn opts) :get-alphas-fn (opts->get-alphas-fn without-opts-rulebase opts))) +(defn reconstruct-node-expr-fn-lookup + "Rebuilds the expr-lookup map from the serialized map to NodeExprFnLookup: + {[Int Keyword] {Keyword Any}} -> {[Int Keyword] [ExprFn {Keyword Any}]} + + Options: + - :read-only? (boolean) or :query-only? (boolean) + - true: the expressions are not compiled, and instead com/read-only-expr is used for all expressions. + - false: the expressions are compiled via com/compile-exprs. + + This is left public to assist in ISessionSerializer durability implementations." + [expr-lookup {:keys [read-only? query-only?] :as opts}] + ;; Rebuilding the expr-lookup map from the serialized map: + ;; {[Int Keyword] {Keyword Any}} -> {[Int Keyword] [SExpr {Keyword Any}]} + (let [read-only (or read-only? query-only?) + node-expr-lookup (into {} + (for [[node-key compilation-ctx] expr-lookup + :let [expr (if read-only + com/read-only-expr + (-> compilation-ctx (get (nth node-key 1))))]] + [node-key [expr compilation-ctx]]))] + (cond-> node-expr-lookup + (not read-only) + (com/compile-exprs opts)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;; Serialization protocols. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -546,9 +588,16 @@ space and time in these scenarios. * :read-only? - When true indicates the rulebase or session should be deserialized in read-only mode, - meaning only queries are allowed, this session can be queried like any other session but rules can - no longer be fired, facts cannot be inserted nor retracted. This session will only contain query nodes - and query beta memory. + meaning only queries and inspection operations are allowed. Rules cannot be fired, and facts cannot + be inserted nor retracted. The session will contain query nodes, query beta memory, and sufficient + infrastructure for inspection operations. + + * :query-only? - When true indicates the rulebase or session should be deserialized in query-only mode, + a more restrictive subset of read-only mode. Only queries are supported (inspection operations are + not available). Rules cannot be fired, and facts cannot be inserted nor retracted. This is a more + aggressive optimization than :read-only?, resulting in smaller memory footprint and serialization + size since only query nodes and query beta memory are preserved. Use this when you need queries + but not inspection. * :with-rulebase? - When true the rulebase is included in the serialized state of the session. The *default* behavior is false when serializing a session via the serialize-session-state function. diff --git a/src/main/clojure/clara/rules/durability/fressian.clj b/src/main/clojure/clara/rules/durability/fressian.clj index a726b4c2..7cf2fcec 100644 --- a/src/main/clojure/clara/rules/durability/fressian.clj +++ b/src/main/clojure/clara/rules/durability/fressian.clj @@ -602,30 +602,24 @@ (with-open [^FressianReader rdr (fres/create-reader in-stream :handlers read-handler-lookup)] (let [{:keys [base-rulebase rulebase-only? + query-only? read-only?]} opts + read-only (or read-only? query-only?) record-holder (ArrayList.) ;; The rulebase should either be given from the base-session or found in ;; the restored session-state. maybe-base-rulebase (when (and (not rulebase-only?) base-rulebase) base-rulebase) - reconstruct-expressions (fn [expr-lookup] - ;; Rebuilding the expr-lookup map from the serialized map: - ;; {[Int Keyword] {Keyword Any}} -> {[Int Keyword] [SExpr {Keyword Any}]} - (into {} - (for [[node-key compilation-ctx] expr-lookup] - [node-key [(-> compilation-ctx (get (nth node-key 1))) - compilation-ctx]]))) - rulebase (if maybe-base-rulebase maybe-base-rulebase (let [without-opts-rulebase - (binding [d/*node-id->node-cache* (atom {}) + (binding [d/*read-only* read-only + d/*node-id->node-cache* (atom {}) d/*clj-struct-holder* record-holder] (binding [d/*node-fn-cache* (-> (fres/read-object rdr) - (reconstruct-expressions) - (com/compile-exprs opts))] + (d/reconstruct-node-expr-fn-lookup opts))] (assoc (fres/read-object rdr) :node-expr-fn-lookup d/*node-fn-cache*)))] diff --git a/src/main/clojure/clara/rules/engine.clj b/src/main/clojure/clara/rules/engine.clj index d6ba13c7..3d0ede25 100644 --- a/src/main/clojure/clara/rules/engine.clj +++ b/src/main/clojure/clara/rules/engine.clj @@ -2213,6 +2213,71 @@ [session] (assemble-read-only (components session))) +(defn rulebase->query-only-rulebase + "Constructs a new query-only rulebase from an existing rulebase. The query-only network + contains only query nodes and strips out all production-related infrastructure: + - Alpha and beta root nodes (not needed for queries) + - Production nodes and rules (queries don't fire rules) + - Activation group functions (no rule firing) + - Node expression functions (queries use pre-compiled expressions) + + This significantly reduces memory footprint for sessions that only need to execute queries. + Query nodes are preserved through the original rulebase structure's :query-nodes map." + [rulebase] + (assoc rulebase + :alpha-roots {} + :beta-roots [] + :productions #{} + :production-nodes [] + :id-to-node {} + :activation-group-sort-fn nil + :activation-group-fn nil + :get-alphas-fn nil + :node-expr-fn-lookup {})) + +(defn memory->query-only-beta-memory + "Constructs a query only beta memory from a memory, the query only beta memory only contains the beta memory for query nodes" + [{:keys [rulebase] :as memory}] + (let [{:keys [query-nodes]} rulebase + query-node-set (set (vals query-nodes))] + (into {} + (for [query-node query-node-set + :let [node-id (:id query-node) + node-memory (mem/get-tokens-map memory query-node)] + :when (seq node-memory)] + [node-id node-memory])))) + +(defn assemble-query-only + "Assembles a query-only session from the given components. A query-only session is a more + restrictive subset of a read-only session - it supports queries only, not inspection operations. + + This function: + 1. Creates a minimal rulebase containing only query nodes + 2. Extracts only the beta memory needed for those query nodes + 3. Constructs a ReadOnlyLocalSession with the minimal rulebase and memory + + Query-only sessions have significantly smaller memory footprint and serialization size compared + to both full sessions and read-only sessions, making them ideal for scenarios where only + query execution is needed." + [{:keys [rulebase memory transport listeners get-alphas-fn]}] + (let [query-only-rulebase (rulebase->query-only-rulebase rulebase) + query-only-beta-memory (memory->query-only-beta-memory memory) + query-only-memory (mem/map->PersistentLocalMemory {:rulebase query-only-rulebase + :beta-memory query-only-beta-memory})] + (ReadOnlyLocalSession. query-only-rulebase + query-only-memory + transport + (l/combine-listeners listeners) + get-alphas-fn))) + +(defn as-query-only + "Converts an existing session into a query-only session. The resulting session supports + only query execution - inspection operations, rule firing, and fact insertion/retraction + are not available. This is a more aggressive optimization than as-read-only, resulting + in smaller memory footprint since only query nodes and their beta memory are preserved." + [session] + (assemble-query-only (components session))) + (defn with-listener "Return a new session with the listener added to the provided session, in addition to all listeners previously on the session." diff --git a/src/test/clojure/clara/test_durability.clj b/src/test/clojure/clara/test_durability.clj index e6f7017e..8c60deb3 100644 --- a/src/test/clojure/clara/test_durability.clj +++ b/src/test/clojure/clara/test_durability.clj @@ -209,6 +209,7 @@ restored-rulebase (d/deserialize-rulebase (mk-rulebase-serializer)) ro-restored-rulebase (d/deserialize-rulebase (mk-rulebase-serializer) {:read-only? true}) + qo-restored-rulebase (d/deserialize-rulebase (mk-rulebase-serializer) {:query-only? true}) restored (d/deserialize-session-state (mk-session-serializer) mem-serializer {:base-rulebase restored-rulebase}) @@ -216,17 +217,26 @@ mem-serializer {:base-rulebase ro-restored-rulebase :read-only? true}) + qo-restored (d/deserialize-session-state (mk-session-serializer) + mem-serializer + {:base-rulebase qo-restored-rulebase + :query-only? true}) r-unpaired-res (query restored dr/unpaired-wind-speed) ro-unpaired-res (query ro-restored dr/unpaired-wind-speed) + qo-unpaired-res (query qo-restored dr/unpaired-wind-speed) r-cold-res (query restored dr/cold-temp) ro-cold-res (query ro-restored dr/cold-temp) + qo-cold-res (query qo-restored dr/cold-temp) r-hot-res (query restored dr/hot-temp) ro-hot-res (query ro-restored dr/hot-temp) + qo-hot-res (query qo-restored dr/hot-temp) r-temp-his-res (query restored dr/temp-his) ro-temp-his-res (query ro-restored dr/temp-his) + qo-temp-his-res (query qo-restored dr/temp-his) r-temps-under-thresh-res (query restored dr/temps-under-thresh) ro-temps-under-thresh-res (query ro-restored dr/temps-under-thresh) + qo-temps-under-thresh-res (query qo-restored dr/temps-under-thresh) facts (sort-by hash @(:holder mem-serializer))] (testing "Ensure restored read-only session" @@ -245,33 +255,54 @@ (is (thrown? UnsupportedOperationException (retract ro-restored)))) + (testing "Ensure restored query-only session" + (is (instance? ReadOnlyLocalSession qo-restored)) + (let [component-keys [:rulebase :memory :transport :listeners :get-alphas-fn] + components (eng/components qo-restored)] + (doseq [component-key component-keys] + (is (some? (get components component-key)) + (str "Read-only restored session should have component: " component-key)))) + (is (thrown? UnsupportedOperationException + (fire-rules qo-restored))) + (is (thrown? UnsupportedOperationException + (fire-rules-async qo-restored))) + (is (thrown? UnsupportedOperationException + (insert qo-restored))) + (is (thrown? UnsupportedOperationException + (retract qo-restored)))) + (testing "Ensure the queries return same before and after serialization" (is (= (frequencies [{:?ws (dr/->UnpairedWindSpeed ws10)}]) (frequencies unpaired-res) (frequencies r-unpaired-res) - (frequencies ro-unpaired-res))) + (frequencies ro-unpaired-res) + (frequencies qo-unpaired-res))) (is (= (frequencies [{:?c (->Cold 20)}]) (frequencies cold-res) (frequencies r-cold-res) - (frequencies ro-cold-res))) + (frequencies ro-cold-res) + (frequencies qo-cold-res))) (is (= (frequencies [{:?h (->Hot 50)} {:?h (->Hot 40)} {:?h (->Hot 30)}]) (frequencies hot-res) (frequencies r-hot-res) - (frequencies ro-hot-res))) + (frequencies ro-hot-res) + (frequencies qo-hot-res))) (is (= (frequencies [{:?his (->TemperatureHistory [50 40 30 20])}]) (frequencies temp-his-res) (frequencies r-temp-his-res) - (frequencies ro-temp-his-res))) + (frequencies ro-temp-his-res) + (frequencies qo-temp-his-res))) (is (= (frequencies [{:?tut (dr/->TempsUnderThreshold [temp40 temp30 temp20])}]) (frequencies temps-under-thresh-res) (frequencies r-temps-under-thresh-res) - (frequencies ro-temps-under-thresh-res)))) + (frequencies ro-temps-under-thresh-res) + (frequencies qo-temps-under-thresh-res)))) (testing "metadata is preserved on rulebase nodes" (let [node-with-meta (->> s diff --git a/src/test/clojure/clara/test_rules.clj b/src/test/clojure/clara/test_rules.clj index 942e3da2..d558d83d 100644 --- a/src/test/clojure/clara/test_rules.clj +++ b/src/test/clojure/clara/test_rules.clj @@ -2522,10 +2522,12 @@ (insert (->Temperature 15 "MCI")) (insert (->WindSpeed 45 "MCI")) (fire-rules)) - read-only-session (eng/as-read-only session)] + read-only-session (eng/as-read-only session) + query-only-session (eng/as-query-only session)] (is (= [{:?fact (->ColdAndWindy 15 45)}] (query session ::rules-data/find-cold-and-windy-data) - (query read-only-session ::rules-data/find-cold-and-windy-data))))) + (query read-only-session ::rules-data/find-cold-and-windy-data) + (query query-only-session ::rules-data/find-cold-and-windy-data))))) ;;; Verify that an exception is thrown when a duplicate name is encountered. ;;; Note we create the session with com/mk-session*, as com/mk-session allows