Expand description
Logic Line - a logic processing engine for Rust
§Introduction
Processing system state and events has got the following problems:
-
Visual programming languages are good for visualizing the flow of data, but they are extremely limited in terms using them for large and complex logic rule sets.
-
Text-based programming languages are good for writing complex logic, but they lack the ability to visualize the flow of data, both for schema validation in development and for flow visualization in debugging or production.
-
If the set contains a lot of rules, their notation must be as compact as possible, otherwise it becomes unreadable and hard to maintain.
Logic Line is a logic processing engine that combines the best of both worlds. Inspired by Ladder Logic, Monads and some others, it allows to write chains of logic rules as a regular Rust code, but provides built-in tools for state recording, debugging and visualization.
§Architecture
The library has the following components:
Rack → Processor → Line → Step → Action
Where:
- Rack is a logic state either for the whole process or for a group of
rules. In the first case, a
global
module can be used which contains a pre-defined process global instance. Rack can also act as an object factory forProcessor
instances, in case if created from aRack
instance, the processors have the common recording flag.
use logicline::Rack;
let rack = Rack::new();
- Processor is a logic processor that processes a chain of rules. The
processor creates
lines
. The processors can share common state between threads or other program parts.
use logicline::Rack;
let rack = Rack::new();
let processor = rack.processor();
-
Line is an instance which is used to structure logic as a sequence of steps (monad-like objects). Each step includes a single or multiple action objects which are
std::ops::FnOnce
instances, wrapped into a structure which also contains the function name and its input parameter:(M a) → (a → M b) → (M b)
use logicline::{action, Rack};
let mut rack = Rack::new().with_recording_enabled();
let mut processor = rack.processor();
// Some fan state
let mut fan = false;
// A temperature sensor value
let temperature = 31.0;
processor
// a sequence to turn on the fan on if the temperature is above 30 degrees
.line("fan_on", temperature)
.then(action!("temp_high", |t| (t > 30.0).then_some(())))
.then(action!("fan_on", |()| {
fan = true;
Some(())
}));
processor
// a sequence to turn off the fan if the temperature is below 25 degrees
.line("fan_off", temperature)
.then(action!("temp_low", |t| (t < 25.0).then_some(())))
.then(action!("fan_off", |()| {
fan = false;
Some(())
}));
rack.ingress(&mut processor);
When recorded, the rack state can be printed in a human-readable format:
println!("{}", rack);
fan_off: temp_low(31.0) ! -> fan_off
fan_on: temp_high(31.0) -> fan_on
Or serialized, e.g. to JSON for web visualization:
let serialized = serde_json::to_string_pretty(&rack).unwrap();
{
"lines": {
"fan_off": {
"name": "fan_off",
"steps": [
{
"name": "temp_low",
"input": 31.0,
"input_kind": "flow",
"passed": false
},
{
"name": "fan_off",
"input": null,
"input_kind": "flow",
"passed": false
}
]
},
"fan_on": {
"name": "fan_on",
"steps": [
{
"name": "temp_high",
"input": 31.0,
"input_kind": "flow",
"passed": true
},
{
"name": "fan_on",
"input": null,
"input_kind": "flow",
"passed": true
}
]
}
}
}
Technically, Step
repeats certain std::option::Option
functionality,
but adds features to record and visualize the flow of data:
-
All lines and steps are named.
-
Step inputs are recorded.
Additionally, Step
brings logical OR
operation, which allows to combine
two following closures in a single step. The closures must accept the same
input type and the input must implement Clone
trait.
use logicline::{action, Rack};
// Here we use Env as a reference, so the `Clone` trait is not required for the
// structure itself.
#[derive(serde::Serialize)]
struct Env {
temperature: f32,
humidity: f32,
}
let env = Env {
temperature: 25.0,
humidity: 40.0,
};
let rack = Rack::new();
let mut processor = rack.processor();
let mut env_healthy = true;
processor
.line("env_unhealthy", &env)
.then_any(
action!("temp_high", |env: &Env| (env.temperature > 30.0).then_some(())),
action!("humidity_high", |env: &Env| (env.humidity > 60.0).then_some(())),
)
.then(action!("set_unhealthy", |()| {
env_healthy = false;
Some(())
}));
The same example without the structure, where the second closure accepts an external variable which is recorded as an input:
use logicline::{action, Rack};
let rack = Rack::new();
let mut processor = rack.processor();
let temperature = 25.0;
let humidity = 40.0;
let mut env_healthy = false;
processor
// the temperature sensor value
.line("env_healthy", temperature)
.then_any(
action!("temp_ok", |t| (t < 30.0).then_some(())),
// skip the chain input, the method `with_recorded_input` is used to
// record the real closure input
action!("humidity_ok", |_| (humidity < 60.0).then_some(()))
.with_recorded_input(&humidity)
)
.then(action!("set_healthy", |()| {
env_healthy = true;
Some(())
}));
As Rust has got no exception, the way to break the chain is the same as for the
traditional combinators: return None
.
§Recording
By default recording
feature is enabled. When disabled, no line state is
recorded, certain recording-specific methods are not available.
The state recording can be also enabled/disabled in runtime. By default, the runtime recording is disabled.
§Ordering
In a classic logic rack, it is supposed that the order of the lines is unpredictable, as classic logic programming languages copy hardware relay-rack logic.
In this library, the order of the lines is defined by the developer however it is strongly recommended to avoid creating conflicting lines to keep the overall state clear and consistent.
The recorded lines are placed into a std::collections::BTreeMap
and
automatically sorted by their names.
§Performance
When recording is disabled (either feature or the runtime state/processor
flag), the logic lines bring almost no overhead in comparison to the
traditional combinators, such as similar methods of std::option::Option
and
std::result::Result
.
When recording is enabled, it is recommended to clone rack instances before
processing them in case if the instances are under Mutex or RwLock. This can be
performed either with Rack::clone
with Rack::snapshot
methods. The
second variant returns a Snapshot
instance which contains line states only.
§Data visualization
The crate exporter
feature provides a built-in exporter (HTTP server) for
global
process state.
logicline::global::install_exporter().unwrap();
The web server binds to the port 9001
and provides the global rack state
snapshots in JSON format at the /state
endpoint. The server can be also
configured to bind a specific address using global::install_exporter_on
method.
The snapshots can be visualized using
logicline-view
TypeScript library which is a part of this project.
In case if a feature exporter-ui
is enabled, the built-in web server also
provides a basic interface to visualize the global state snapshots. The
interface is available at the root (http://host:9001/
) endpoint.
For custom programs, state snapshots can be serialized to any
serde
-compatible format and pushed/pulled in any required way.
In case of periodic processing, such as local/remote context/sensor analysis in traditional PLC logic state is update on every iteration and the visualization always contains all the lines programmed.
In case of event-processing model, it is recommended to send dummy events at the program start to fill the logic state with the initial chain values.
§Data safety
Some logic chains might contain sensitive data, which should be hidden either from everyone or accessible only for users with certain permissions.
The default exporter provides a built-in way to post-format data snapshots. Let
us hide inputs for all steps with name starting with voltage
:
struct SensitiveDataFormatter {}
impl logicline::SnapshotFormatter for SensitiveDataFormatter {
fn format(&self, mut snapshot: Snapshot) -> Snapshot {
for line in snapshot.lines_mut().values_mut() {
for step in line.steps_mut() {
for i in step.info_mut() {
// Step data is under Arc to let it be cloned without
// overhead, so we need to replace the original step data
// with the new one. In this example a helper method is used.
if i.name().starts_with("voltage") {
*i = i.to_modified(
None,
Some(serde_json::Value::String("<hidden>".to_owned())),
None,
None,
);
}
}
}
}
snapshot
}
}
// then in the main function
logicline::global::set_snapshot_formatter(Box::new(SensitiveDataFormatter {}));
The assigned formatter formats all snapshots requested by the exporter. To apply a similar logic in own exporter (e.g. with authorization implemented), consider using a similar way (with or without the provided trait).
§Locking safety
By default, the crate (both the server and the client modules) uses parking_lot for locking. For real-time applications, the following features are available:
-
locking-rt
- use parking_lot_rt crate which is a spin-free fork of parking_lot. -
locking-rt-safe
- use rtsc priority-inheritance locking, which is not affected by priority inversion (Linux only).
Note: to switch locking policy, disable the crate default features.
§About
Logic Line is a part of RoboPLC project.
Modules§
Macros§
- action
- Creates a new action, in case if the name is not provided, the function name will be used as the name of the action (in case of closures it is recommended to always provide a name to get it clear and readable).
Structs§
- Action
- Action is a function wrapper that can be used in a step
- Line
State - State of the logical line
- Processor
- Processor is an instance which creates logical lines
- Rack
- State of the process or a group of logic lines. Acts as a factory for
Processor
instances. Shares recording state with the created processors. - Snapshot
- State snapshot
- Step
- Logical step in the line
- Step
State Info - Single step state information
Enums§
- Input
Kind - Input kind, flow: taken from the previous action, external: specified by the user
- Step
State - Line step state
Traits§
- Snapshot
Formatter - Modifies snapshots before serving/displaying
- Step
Input - When the recording feature is enabled, inputs must implement the
serde::Serialize
trait