Browse Source

feat: Adding cron trigger function

Matthias Ladkau 3 years ago
parent
commit
b9a925e614

BIN
cli/cli


+ 1 - 2
cli/ecal.go

@@ -21,12 +21,9 @@ import (
 
 /*
 TODO:
-- CLI interpreter (show base directory when starting)
-- adding external stdlib functions
 - cron trigger (async) in-build function
 - web server (sync/async) in-build function with options
+- web request library
 - create executable binary (pack into single binary)
 - debug server support (vscode)
 */

+ 40 - 1
ecal.md

@@ -461,6 +461,43 @@ Example:
 doc(len)
 ```
 
+#### `setCronTrigger(cronspec, eventname, eventkind) : string`
+Adds a periodic cron job which fires events. Use this function for long running
+periodic tasks.
+
+The function requires a cronspec which defines the time schedule in which events
+should be fired. The cronspec is a single text string which must have the
+following 6 entries separated by whitespace:
+
+```
+Field	         Valid values
+-----	         ------------
+second         * or 0-59 or *%1-59
+minute         * or 0-59 or *%1-59
+hour           * or 0-23 or *%1-23
+day of month   * or 1-31 or *%1-31
+month          * or 1-12 or *%1-12
+day of week    * or 0-6 (0 is Sunday) or *%1-7
+```
+
+Multiple values for an entry can be separated by commas e.g. `1,3,5,7`.
+A `*` in any field matches all values i.e. execute every minute, every
+day, etc. A `*%<number>` in any field entry matches when the time is a
+multiple of <number>.
+
+Returns a human readable string representing the cronspec.
+
+For example `0 0 12 1 * *` returns `at the beginning of hour 12:00 on 1st of every month`.
+
+Parameter | Description
+-|-
+cronspec  | The cron job specification string
+eventname | Event name for the cron triggered events
+eventkind | Event kind for the cron triggered events
+
+#### `setPulseTrigger() : string`
+
+
 Logging Functions
 --
 ECAL has a build-in logging system and provides by default the functions `debug`, `log` and `error` to log messages.
@@ -469,7 +506,9 @@ Stdlib Functions
 --
 ECAL contains a bridge to Go functions which allows some Go functions to be used as standard library (stdlib) functions. Stdlib functions should be called using the corresponding Go Module and function or constant name.
 
+By default only some `math` constants are available it is possible to include other constants and functions using code generation (as part of the normal build process of the ECAL interpreter). Please see the comments and modify the file `/stdlib/generate/generate.go` and then run a normal `make` to build a new ECAL interpreter with extended stdlib.
+
 Example:
 ```
-fmt.Sprint(math.Pi)
+log(math.Pi)
 ```

+ 67 - 0
interpreter/func_provider.go

@@ -14,7 +14,10 @@ import (
 	"fmt"
 	"strconv"
 	"strings"
+	"time"
 
+	"devt.de/krotik/common/errorutil"
+	"devt.de/krotik/common/timeutil"
 	"devt.de/krotik/ecal/engine"
 	"devt.de/krotik/ecal/parser"
 	"devt.de/krotik/ecal/scope"
@@ -37,6 +40,7 @@ var InbuildFuncMap = map[string]util.ECALFunction{
 	"raise":           &raise{&inbuildBaseFunc{}},
 	"addEvent":        &addevent{&inbuildBaseFunc{}},
 	"addEventAndWait": &addeventandwait{&addevent{&inbuildBaseFunc{}}},
+	"setCronTrigger":  &setCronTrigger{&inbuildBaseFunc{}},
 }
 
 /*
@@ -743,3 +747,66 @@ func (rf *addeventandwait) DocString() (string, error) {
 	return "AddEventAndWait adds an event to trigger sinks. This function will " +
 		"return once the event cascade has finished.", nil
 }
+
+// setCronTrigger
+// ==============
+
+/*
+setCronTrigger adds a periodic cron job which fires events.
+*/
+type setCronTrigger struct {
+	*inbuildBaseFunc
+}
+
+/*
+Run executes this function.
+*/
+func (ct *setCronTrigger) Run(instanceID string, vs parser.Scope, is map[string]interface{}, args []interface{}) (interface{}, error) {
+	var res interface{}
+	err := fmt.Errorf("Need a cronspec, an event name and an event scope as parameters")
+
+	if len(args) > 2 {
+		var cs *timeutil.CronSpec
+
+		cronspec := fmt.Sprint(args[0])
+		eventname := fmt.Sprint(args[1])
+		eventkind := strings.Split(fmt.Sprint(args[2]), ".")
+
+		erp := is["erp"].(*ECALRuntimeProvider)
+		proc := erp.Processor
+
+		if proc.Stopped() {
+			proc.Start()
+		}
+
+		if cs, err = timeutil.NewCronSpec(cronspec); err == nil {
+			res = cs.String()
+
+			tick := 0
+
+			erp.Cron.RegisterSpec(cs, func() {
+				tick += 1
+				now := erp.Cron.NowFunc()
+				event := engine.NewEvent(eventname, eventkind, map[interface{}]interface{}{
+					"time":      now,
+					"timestamp": fmt.Sprintf("%d", now.UnixNano()/int64(time.Millisecond)),
+					"tick":      tick,
+				})
+				monitor := proc.NewRootMonitor(nil, nil)
+				_, err := proc.AddEvent(event, monitor)
+				errorutil.AssertTrue(err == nil,
+					fmt.Sprintf("Could not add cron event for trigger %v %v %v: %v",
+						cronspec, eventname, eventkind, err))
+			})
+		}
+	}
+
+	return res, err
+}
+
+/*
+DocString returns a descriptive string.
+*/
+func (ct *setCronTrigger) DocString() (string, error) {
+	return "setCronTrigger adds a periodic cron job which fires events.", nil
+}

+ 66 - 0
interpreter/func_provider_test.go

@@ -14,6 +14,7 @@ import (
 	"fmt"
 	"reflect"
 	"testing"
+	"time"
 
 	"devt.de/krotik/ecal/stdlib"
 )
@@ -316,6 +317,71 @@ identifier: a
 
 }
 
+func TestCronTrigger(t *testing.T) {
+
+	res, err := UnitTestEval(
+		`setCronTrigger("1 * * * *", "foo", "bar")`, nil)
+
+	if err == nil ||
+		err.Error() != "ECAL error in ECALTestRuntime: Runtime error (Cron spec must have 6 entries separated by space) (Line:1 Pos:1)" {
+		t.Error("Unexpected result: ", res, err)
+		return
+	}
+
+	res, err = UnitTestEval(
+		`
+sink test
+  kindmatch [ "foo.*" ],
+{
+	log("test rule - Handling request: ", event)
+}
+
+log("Cron:", setCronTrigger("1 1 *%10 * * *", "cronevent", "foo.bar"))
+`, nil)
+
+	if err != nil {
+		t.Error("Unexpected result:", err)
+		return
+	}
+
+	testcron.Start()
+	time.Sleep(100 * time.Millisecond)
+
+	if testlogger.String() != `
+Cron:at second 1 of minute 1 of every 10th hour every day
+test rule - Handling request: {
+  "kind": "foo.bar",
+  "name": "cronevent",
+  "state": {
+    "tick": 1,
+    "time": "2000-01-01T00:01:01Z",
+    "timestamp": "946684861000"
+  }
+}
+test rule - Handling request: {
+  "kind": "foo.bar",
+  "name": "cronevent",
+  "state": {
+    "tick": 2,
+    "time": "2000-01-01T10:01:01Z",
+    "timestamp": "946720861000"
+  }
+}
+test rule - Handling request: {
+  "kind": "foo.bar",
+  "name": "cronevent",
+  "state": {
+    "tick": 3,
+    "time": "2000-01-01T20:01:01Z",
+    "timestamp": "946756861000"
+  }
+}`[1:] {
+		t.Error("Unexpected result:", testlogger.String())
+		return
+	}
+
+}
+
 func TestDocstrings(t *testing.T) {
 	for k, v := range InbuildFuncMap {
 		if res, _ := v.DocString(); res == "" {

+ 17 - 0
interpreter/main_test.go

@@ -17,6 +17,7 @@ import (
 	"testing"
 
 	"devt.de/krotik/common/datautil"
+	"devt.de/krotik/common/timeutil"
 	"devt.de/krotik/ecal/engine"
 	"devt.de/krotik/ecal/parser"
 	"devt.de/krotik/ecal/scope"
@@ -54,6 +55,10 @@ var usedNodes = map[string]bool{
 //
 var testlogger *util.MemoryLogger
 
+// Last used cron
+//
+var testcron *timeutil.Cron
+
 // Last used processor
 //
 var testprocessor engine.Processor
@@ -84,6 +89,18 @@ func UnitTestEvalAndASTAndImport(input string, vs parser.Scope, expectedAST stri
 	erp := NewECALRuntimeProvider("ECALTestRuntime", importLocator, nil)
 
 	testlogger = erp.Logger.(*util.MemoryLogger)
+
+	// For testing we change the cron object to be a testing cron which goes
+	// quickly through a day when started. To test cron functionality a test
+	// needs to first specify a setCronTrigger and the sinks. Once this has
+	// been done the testcron object needs to be started. It will go through
+	// a day instantly and add a deterministic number of events (according to
+	// the cronspec given to setCronTrigger for one day).
+
+	erp.Cron.Stop()
+	erp.Cron = timeutil.NewTestingCronDay()
+	testcron = erp.Cron
+
 	testprocessor = erp.Processor
 
 	ast, err := parser.ParseWithRuntime("ECALEvalTest", input, erp)

+ 6 - 1
interpreter/provider.go

@@ -14,6 +14,7 @@ import (
 	"os"
 	"path/filepath"
 
+	"devt.de/krotik/common/timeutil"
 	"devt.de/krotik/ecal/config"
 	"devt.de/krotik/ecal/engine"
 	"devt.de/krotik/ecal/parser"
@@ -138,6 +139,7 @@ type ECALRuntimeProvider struct {
 	ImportLocator util.ECALImportLocator // Locator object for imports
 	Logger        util.Logger            // Logger object for log messages
 	Processor     engine.Processor       // Processor of the ECA engine
+	Cron          *timeutil.Cron         // Cron object for scheduled execution
 }
 
 /*
@@ -166,7 +168,10 @@ func NewECALRuntimeProvider(name string, importLocator util.ECALImportLocator, l
 
 	proc.SetFailOnFirstErrorInTriggerSequence(true)
 
-	return &ECALRuntimeProvider{name, importLocator, logger, proc}
+	cron := timeutil.NewCron()
+	cron.Start()
+
+	return &ECALRuntimeProvider{name, importLocator, logger, proc, cron}
 }
 
 /*

+ 3 - 2
stdlib/generate/generate.go

@@ -74,7 +74,7 @@ func main() {
 	// Make sure we have at least an empty pkgName
 
 	if len(pkgNames) == 0 {
-		pkgNames["math"] = []string{"Pi"}
+		pkgNames["math"] = []string{"Pi", "E", "Phi"}
 	}
 
 	// Make sure pkgNames is sorted
@@ -83,6 +83,7 @@ func main() {
 	for pkgName, names := range pkgNames {
 		sort.Strings(names)
 		importList = append(importList, pkgName)
+		synopsis["math"] = "Mathematics-related constants and functions"
 	}
 	sort.Strings(importList)
 
@@ -101,7 +102,7 @@ package stdlib
 			errorutil.AssertOk(err) // If this throws try not generating the docs!
 			synopsis[pkgName] = syn
 			pkgDocs[pkgName] = pkgDoc
-		} else {
+		} else if _, ok := synopsis[pkgName]; !ok {
 			synopsis[pkgName] = fmt.Sprintf("Package %v", pkgName)
 		}
 

+ 1 - 1
stdlib/generate/generate_test.go

@@ -80,7 +80,7 @@ var genStdlib = map[interface{}]interface{}{
 	"fmt-const" : fmtConstMap,
 	"fmt-func" : fmtFuncMap,
 	"fmt-func-doc" : fmtFuncDocMap,
-	"math-synopsis" : "Package math",
+	"math-synopsis" : "Mathematics-related constants and functions",
 	"math-const" : mathConstMap,
 	"math-func" : mathFuncMap,
 	"math-func-doc" : mathFuncDocMap,