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