From fd16109b03d15f8a2095d0bf0557859f7861d752 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Wed, 28 Jan 2026 13:43:48 -0600 Subject: [PATCH 1/4] chore: read-only session durability enhancements --- src/main/clojure/clara/rules/compiler.clj | 4 ++- src/main/clojure/clara/rules/durability.clj | 29 +++++++++++++++++++ .../clara/rules/durability/fressian.clj | 14 ++------- 3 files changed, 35 insertions(+), 12 deletions(-) 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..4cc92e3f 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." @@ -527,6 +533,29 @@ :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) + - 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?] :as opts}] + ;; Rebuilding the expr-lookup map from the serialized map: + ;; {[Int Keyword] {Keyword Any}} -> {[Int Keyword] [SExpr {Keyword Any}]} + (let [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. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/src/main/clojure/clara/rules/durability/fressian.clj b/src/main/clojure/clara/rules/durability/fressian.clj index a726b4c2..cf2379c8 100644 --- a/src/main/clojure/clara/rules/durability/fressian.clj +++ b/src/main/clojure/clara/rules/durability/fressian.clj @@ -610,22 +610,14 @@ 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*)))] From 11795936c4b84660ea7d88aed32aefc7a86b68a7 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Wed, 28 Jan 2026 14:26:24 -0600 Subject: [PATCH 2/4] feat: add support for query-only sessions which are also read-only --- src/main/clojure/clara/rules/durability.clj | 36 ++++++++++++---- .../clara/rules/durability/fressian.clj | 4 +- src/main/clojure/clara/rules/engine.clj | 42 +++++++++++++++++++ src/test/clojure/clara/test_durability.clj | 41 +++++++++++++++--- src/test/clojure/clara/test_rules.clj | 6 ++- 5 files changed, 112 insertions(+), 17 deletions(-) diff --git a/src/main/clojure/clara/rules/durability.clj b/src/main/clojure/clara/rules/durability.clj index 4cc92e3f..b7754f77 100644 --- a/src/main/clojure/clara/rules/durability.clj +++ b/src/main/clojure/clara/rules/durability.clj @@ -494,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) @@ -504,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) @@ -519,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 @@ -538,22 +550,23 @@ {[Int Keyword] {Keyword Any}} -> {[Int Keyword] [ExprFn {Keyword Any}]} Options: - - :read-only? (boolean) + - :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?] :as opts}] + [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 [node-expr-lookup (into {} + (let [read-only (or read-only? query-only?) + node-expr-lookup (into {} (for [[node-key compilation-ctx] expr-lookup - :let [expr (if read-only? + :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?) + (not read-only) (com/compile-exprs opts)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -579,6 +592,11 @@ no longer be fired, facts cannot be inserted nor retracted. This session will only contain query nodes and query beta memory. + * :query-only? - When true indicates the rulebase or session should be deserialized in query-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. + * :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 cf2379c8..7cf2fcec 100644 --- a/src/main/clojure/clara/rules/durability/fressian.clj +++ b/src/main/clojure/clara/rules/durability/fressian.clj @@ -602,8 +602,10 @@ (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. @@ -613,7 +615,7 @@ rulebase (if maybe-base-rulebase maybe-base-rulebase (let [without-opts-rulebase - (binding [d/*read-only* read-only? + (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) diff --git a/src/main/clojure/clara/rules/engine.clj b/src/main/clojure/clara/rules/engine.clj index d6ba13c7..f58816fb 100644 --- a/src/main/clojure/clara/rules/engine.clj +++ b/src/main/clojure/clara/rules/engine.clj @@ -2213,6 +2213,48 @@ [session] (assemble-read-only (components session))) +(defn rulebase->query-only-rulebase + "Construcs a query only network from a rulebase, the query only network only contains query nodes" + [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 + [{: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 + [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 From be6d2e1a65e4f47330589766a0b6478eda77a6f9 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Wed, 28 Jan 2026 14:54:03 -0600 Subject: [PATCH 3/4] chore: update docstrigs for more clarify about read-only vs query-only --- src/main/clojure/clara/rules/durability.clj | 14 +++++++----- src/main/clojure/clara/rules/engine.clj | 25 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/main/clojure/clara/rules/durability.clj b/src/main/clojure/clara/rules/durability.clj index b7754f77..f0ae475b 100644 --- a/src/main/clojure/clara/rules/durability.clj +++ b/src/main/clojure/clara/rules/durability.clj @@ -588,14 +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, - 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. + 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/engine.clj b/src/main/clojure/clara/rules/engine.clj index f58816fb..3d0ede25 100644 --- a/src/main/clojure/clara/rules/engine.clj +++ b/src/main/clojure/clara/rules/engine.clj @@ -2214,7 +2214,15 @@ (assemble-read-only (components session))) (defn rulebase->query-only-rulebase - "Construcs a query only network from a rulebase, the query only network only contains query nodes" + "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 {} @@ -2240,6 +2248,17 @@ [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) @@ -2252,6 +2271,10 @@ 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))) From 7ba1ec302a14e02d83d275bc86a7fb3291c09707 Mon Sep 17 00:00:00 2001 From: Jose Gomez Date: Wed, 28 Jan 2026 14:57:16 -0600 Subject: [PATCH 4/4] chore: prepare for future release --- CHANGELOG.md | 5 +++++ pom.xml | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) 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