Design Pattern: Rule Latching

Tags: #<Tag:0x00007fc3ec08bf80>

Please see Design Pattern: What is a Design Pattern and How Do I Use Them for details on how to work with DPs.

Problem Statement

Sometimes one wants to make sure that only one instance of a Rule is running at a given time. For example, when a rule is triggered by a Group Item’s receiving updates, the Rule gets triggered more than once per event. Another use is if one needs to implement debouncing where events that take place within a certain time are ignored…

Concept 1: Debounce

image

Use the existence of a time stamp to indicate whether or not the Rule is locked. If the timestamp is in the future, ignore the rule trigger.

Python

The following is a library module submitted to the Helper Libraries (https://github.com/openhab-scripters/openhab-helper-libraries/pull/255).

from datetime import datetime, timedelta

class RateLimit(object):
    """
    Keeps a timestamp for when a new call to run is allowed to execute, ignoring
    any calls that take place before that time.
    """

    def __init__(self):
        """ Initializes the timestamp to now. """
        self.until = datetime.now()

    def run(self, func, days=0, hours=0, mins=0, secs=0, msecs=0):
        """
        If it has been long enough since the last time that run was called,
        execute the passed in func. Otherwise ignore the call.

        Arguments:
            - func: The lambda or function to call if allowed.
            - days: Defaults to 0, how many days to wait before allowing run to
            execute again.
            - hours: Defaults to 0, how many hours to wait before allowing run
            to execute again.
            - mins: Defaults to 0, how many minutes to wait before allowing run
            to execute again.
            - secs: Defaults to 0, how many seconds to wait before allowing run
            to execute again.
            - msecs: Defaults to 0, how many milliseconds to wait before
            allowing run to execute again.

            NOTE: The time arguments are additive. For example, to wait for 1
            day 30 minutes one would pass days=1, minutes=30. Floats are
            allowed.
        """
        now = datetime.now()
        if now >= self.until:
            self.until = now + timedelta(days=days, hours=hours, minutes=mins,
                                         seconds=secs, milliseconds=msecs)
            func()

The first time it’s called, create a timestamp for when the function will be allowed to run again. Any calls to run that take place before that time will be ignored.

Usage:

import community.rate_limit import RateLimit

rl = RateLimit()

...

    # Only alert once a day
    if event.itemState < low:
        rl.run(lambda: send_alert("Remember to fill the humidifiers, {} is "
                                  "{}%".format(name, event.itemState),
                                  low_hum.log),
               days=1)

Rules DSL

var DateTime until = null

rule "Latched Rule"
when
    Item MyItem received update
then
    if(until !== null && until.isAfter(now)) return; // skip this event if the timer exists

    until = now.plusDays(1)

    // Rule's code
end

Theory of Operation

When the Rule triggers it checks to see if until is there is a time stamp and if so that the timestamp is for the future. In other cases, replace until with a new time it’s allowed to run again and run the body.

Concept 2: ReentrantLocks

Deprecated. Do not use ReentrantLocks in Rules DSL. See Why have my Rules stopped running? Why Thread::sleep is a bad idea for details. Use Design Pattern: Gate Keeper with a reasonable timeout instead.

If using JSR223 Rules, it is safe to use a lock in the Rule to enforce only one from running at a time.

Related Design Patterns

Design Pattern How Used
Design Pattern: Gate Keeper A way to prevent the same code from executing more than once at a time.
3 Likes

I had trouble in the past that the system did not try long enough to get a lock and the rule sometimes did not run. I modified this reentrant locks to try a little longer to get a lock and it seem to have helped. Now I may have been misunderstanding things, but could it be helpful to give more time to try to get a lock? Of course, there are other edge conditions to be considered for every application. But in case it helps anyone, here the code that worked for me

import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock

rule "My rule"
when
        System started or
        Item Date received update
then
var gotLock = lock.tryLock(2000, TimeUnit.MILLISECONDS)		
	try {
		if (gotLock) {
 // do stuff ( i cut out my rule here for brevity)	    
}		   
 		}
		else logInfo("My rule", "re-entry lock actived")
	}
	finally {
		if(gotLock){
			lock.unlock()
		}
	} 
end

Obviously the part I wanted to point out is this lock.tryLock(2000, TimeUnit.MILLISECONDS) (and the additionally imported library). I used this when I was on a raspberry and it seemed to make it more reliable;

This is actually something that is discussed in the ReentrantLock’s JavaDocs.

Part of the problem, as with everything involved with concurrency, is timing or requests and such. The tryLock method that doesn’t take the timeout says

Even when this lock has been set to use a fair ordering policy, a call to tryLock() will immediately acquire the lock if it is available, whether or not other threads are currently waiting for the lock. This “barging” behavior can be useful in certain circumstances, even though it breaks fairness. If you want to honor the fairness setting for this lock, then use tryLock(0, TimeUnit.SECONDS) which is almost equivalent (it also detects interruption).

The section discussing tryLock with the timeout says:

If this lock has been set to use a fair ordering policy then an available lock will not be acquired if any other threads are waiting for the lock.

So, given that, tryLock() causes the thread to jump to the front of the line which could theoretically starve out previous threads waiting to acquire the lock. By adding the timeout what you are doing is forcing the threads to behave nicely and go for the lock in order. I suspect this explains why adding it worked for you. I bet if you changed the 2000 to 0 it would still work as expected.

Thanks for posting! I’m going to change the OP to use the timeout version instead like your example.

Misses a closing brace :slight_smile:

if (latch.tryLock(0, TimeUnit.SECONDS)) {
1 Like

@rlkoshak Would you consider editing the example in the first post to:

import java.util.concurrent.locks.ReentrantLock

Copying the example as is gives an error.

Also, there’s a typo here

        logErrror("latch", "Error occured in Latched Rule! " + e.toString)

This whole DP is slated to be rewritten, but I’ve added the import for now.

1 Like

Thanks. I missed one.

import java.util.concurrent.TimeUnit

Alternative:

if ( !lock.isLocked )
{
    lock.lock()
    try {
        //TODO
        
        Thread::sleep(OneMinute*5)
    } finally {
        lock.unlock()
    }
}

Although I prefeer the timre variant …

You have to be very very careful using locks like this, and it’s one reason why I didn’t show that version above on purpose.

  1. If there is a type error inside the try block the finally block never runs and the lock will never be unlocked. After this rule gets triggered 5 more times all your rules will stop.

  2. If this rule triggers five times in five minutes for some reason, almost your other rules elm so until the first rule exits. And if your rule continues to trigger it will confine to starve all the rest of your rules.

It’s a really bad idea to block rules using a lock like this. Instead, use the Gate Keeper’s Queue example which will let you put the event on a queue that gradually gets worked off without consuming a rule runtime thread for each event awaiting processing.

OK. Got it. Between the isLocked() and lock() another lock can happen. In this case the second thread needs to wait the 5 minutes :face_with_raised_eyebrow:

Why is in the case of an type error the finally block with the unlock not called?

All I can say is that’s how the underlying Xbase/Xtend language libraries work. I can’t tell you why.

I studier it’s because the language is weakly typed running on a strongly typed language base (Java) when it cannot convert an object’s type to something usable it can’t proceed and execution must ends instead of the exception being thrown.