Skip to content

dynamic loading of rules mostly works, but certain operators fail. #406

@davesargrad

Description

@davesargrad

i am defining and loading my rules dynamically.

I then do the following:

    host = NamedActionRuleHost()

    # Compile + register ALL rulesets
    compiled_by_name: dict[str, dict] = {}
    for ruleset_spec in config.rulesets:
        ruleset_name = ruleset_spec.ruleset
        compiled = compile_ruleset_definition(ruleset_spec)
        print(f"COMPILED RULESET: {ruleset_name}")
        print(compiled)
        compiled_by_name[ruleset_name] = compiled

    host.set_rulesets(compiled_by_name)

where

# rules/rule_host.py
from __future__ import annotations

from durable import engine
from .actions import ACTIONS


class NamedActionRuleHost(engine.Host):
    """
    Durable-rules Host that resolves action names (strings from YAML)
    to registered Python callables.

    This enables fully declarative rulesets where:
      - YAML specifies 'action: <name>'
      - code provides the implementation
    """

    def get_action(self, action_name: str):
        try:
            return ACTIONS[action_name]
        except KeyError as e:
            available = ", ".join(sorted(ACTIONS.keys()))
            raise KeyError(
                f"Unknown action '{action_name}'. " f"Available actions: [{available}]"
            ) from e

I have predicate clauses that use '$neq' these actions dont get fired. compiled_by_name looks like the following.

{
    "r_4":{
        "all":[
            {
                "m":{
                    "$eq":{
                        "strict_identifier":true
                    }
                }
            },
            {
                "m":{
                    "$eq":{
                        "predicate":"at_home"
                    }
                }
            },
            {
                "m":{
                    "$eq":{
                        "first_name":"None"
                    }
                }
            }
        ],
        "run":"mark_not_safe_unknown_in_home"
    },
    "r_5":{
        "all":[
            {
                "m":{
                    "$eq":{
                        "strict_identifier":true
                    }
                }
            },
            {
                "m":{
                    "$eq":{
                        "predicate":"at_home"
                    }
                }
            },
            {
                "m":{
                    "$eq":{
                        "last_name":"None"
                    }
                }
            }
        ],
        "run":"mark_not_safe_unknown_in_home"
    },
    "r_6":{
        "all":[
            {
                "m":{
                    "$eq":{
                        "strict_identifier":true
                    }
                }
            },
            {
                "m":{
                    "$neq":{
                        "first_name":"None"
                    }
                }
            },
            {
                "m":{
                    "$neq":{
                        "last_name":"None"
                    }
                }
            }
        ],
        "run":"mark_safe_known_full_name"
    }
}

Though I get into my mark_not_safe_unknown_in_home action, i never get into my mark_safe_known_full_name action.

I believe that is because host.set_rulesets(compiled_by_name) does not like clauses such as the following:

            {
                "m":{
                    "$neq":{
                        "last_name":"None"
                    }
                }
            }

How do I solve this? The provided reference manual doesnt show how to do this.

https://github.com/jruizgit/rules/blob/master/docs/py/reference.md

Follow Up:

Having dug further I am seeing very inconsistent behaviours. I am now working on a rule that compiles into the following JSON:

{
    "r_2":{
        "all":[
            {
                "m":{
                    "$eq":{
                        "predicate":"at_home"
                    }
                }
            },
            {
                "m":{
                    "$eq":{
                        "first_name":"None"
                    }
                }
            },
            {
                "m":{
                    "$eq":{
                        "last_name":"None"
                    }
                }
            }
        ],
        "run":"mark_not_safe_unknown_in_home"
    }
}

That one action:

@action("mark_not_safe_unknown_in_home")
def mark_not_safe_unknown_in_home(c, **kwargs):
    print("[SAFETY] NOT SAFE:", _safe_dump_message(c), flush=True)

I've dug into trying to understand why the rule engine is invoking an action (mark_not_safe_unknown_in_home) when it should not, and doesnt seem to be invoking an action when it should.

I've been focused on this code within the engines host implementation.


    def do_actions(self, state_handle, complete):
        try:
            result = durable_rules_engine.start_action_for_state(self._handle, state_handle)
            if not result:
                complete(None, None)
            else:
                self._flush_actions(json.loads(result[0]), {'message': json.loads(result[1])}, state_handle, complete)
        except BaseException as error:
            complete(error, None)

I am trying to understand what is triggering the call to self._flush_actions. Unfortunately I've not yet been able to step into start_action_for_state (I think that is where the decision is made) that results in an action being invoked or not.

None of the following posts should invoke an action (since the rule expects both first_name and last_name to be None, and yet I see the mark_not_safe_unknown_in_home action invoked.

safe_post(
        host,
        safety,
        {
            "strict_identifier": True,
            "predicate": "at_home",
            "first_name": "Alice",
            "last_name": "Smith",
        },
        on_unhandled="log",
    )

    safe_post(
        host,
        safety,
        {
            "strict_identifier": True,
            "predicate": "at_home",
            "first_name": "None",
            "last_name": "Smith",
        },
        on_unhandled="log",
    )

    safe_post(
        host,
        safety,
        {
            "strict_identifier": True,
            "predicate": "at_home",
            "first_name": "Alice",
            "last_name": "None",
        },
        on_unhandled="log",
    )

If I comment out any one of those posts (doesnt matter which), then the action is not invoked (as it should NOT be). Its starting to feel like this is a memory overwrite issue: a bug in the durable rules engine. It also smells like a thread safety issue, and perhaps it is.

As you see below, the post that results in the action being invoked is the one where there is a first name Alice, and a last_name None. This is consistent. Of course, since the first_name is Alice, that action should not be invoked at all.

POSTING: {'strict_identifier': True, 'predicate': 'at_home', 'first_name': 'Alice', 'last_name': 'Smith'}
POSTING: {'strict_identifier': True, 'predicate': 'at_home', 'first_name': 'None', 'last_name': 'Smith'}
POSTING: {'strict_identifier': True, 'predicate': 'at_home', 'first_name': 'Alice', 'last_name': 'None'}
[SAFETY] NOT SAFE: { strict_identifier=True, predicate='at_home', first_name='Alice', last_name='None' }

Utility Method for Completeness:

# rules/dispatch.py
from __future__ import annotations

from typing import Any, Mapping
from durable.engine import MessageNotHandledException


def safe_post(
    host, ruleset_name: str, msg: Mapping[str, Any], *, on_unhandled: str = "ignore"
) -> bool:
    """
    Post an event to a durable-rules ruleset.

    Returns:
        True  -> at least one rule handled the message
        False -> no rules handled the message (MessageNotHandledException)

    on_unhandled:
        "ignore" -> swallow silently
        "log"    -> print a debug line (temporary/dev-friendly)
        "raise"  -> re-raise MessageNotHandledException
    """
    try:
        print("POSTING:", msg)
        host.post(ruleset_name, dict(msg))
        return True
    except MessageNotHandledException:
        if on_unhandled == "ignore":
            return False
        if on_unhandled == "log":
            print(f"[rules] Unhandled event for ruleset '{ruleset_name}': {dict(msg)}")
            return False
        if on_unhandled == "raise":
            raise
        raise ValueError(f"Unknown on_unhandled mode: {on_unhandled}")

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions