Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
<groupId>com.github.gateless</groupId>
<artifactId>clara-rules</artifactId>
<name>clara-rules</name>
<version>1.6.5</version>
<version>1.6.6</version>
<scm>
<tag>1.6.5</tag>
<tag>1.6.6</tag>
<url>https://github.com/gateless/clara-rules</url>
<connection>scm:git:git://github.com/gateless/clara-rules.git</connection>
<developerConnection>scm:git:ssh://git@github.com/gateless/clara-rules.git</developerConnection>
Expand Down
4 changes: 3 additions & 1 deletion src/main/clojure/clara/rules/compiler.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
63 changes: 56 additions & 7 deletions src/main/clojure/clara/rules/durability.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand All @@ -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.
Expand Down
16 changes: 5 additions & 11 deletions src/main/clojure/clara/rules/durability/fressian.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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*)))]
Expand Down
65 changes: 65 additions & 0 deletions src/main/clojure/clara/rules/engine.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
41 changes: 36 additions & 5 deletions src/test/clojure/clara/test_durability.clj
Original file line number Diff line number Diff line change
Expand Up @@ -209,24 +209,34 @@

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})
ro-restored (d/deserialize-session-state (mk-session-serializer)
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"
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/test/clojure/clara/test_rules.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down