Thursday, 3 September 2015

Making timer and worker events in Omnis safe

Timer events and the new worker objects in Omnis Studio are two very powerful functions in Omnis. Both use the same internal logic to kick of logic at a given time, for timers this will happen at either fixed intervals or when a specific amount of time has passed, for worker objects the run logic once results have been received from the database in a background thread and the data is ready for the application to deal with.

This however is not without consequence. These events can interrupt whatever code is currently running in the foreground thread and as a result introduce very hard to find bugs if the interruption changes something the code being interrupted wasn't expecting.

TigerLogic have done a good job to ensure these interruptions don't happen willy nilly as code can only be interrupted at specific times. It is however unclear what interruptions are possible. After some experimentation I've found the following scenarios will be interrupted by timer and worker code:
- when the main thread is paused on an enter data command
- at the end of a loop
- when constructing a new window
I'm sure I've forgotten some others.

Generally speaking this works great unless you rely on globals.

The main exception to that however are worker object. They have one big drawback, a worker can not interrupt another worker. Omnis tends to crash when that happens. If you have more then one worker active at a given time and you're in the middle of processing the results of the first worker object (which you'll likely do within a loop), the 2nd worker can interrupt that loop as it has finished retrieving its result set. 9 out of 10 times this will crash your application.

It is therefor important to check if you are interrupting code that could potentially lead to issues and delay the action till later. At the start of my $completed function for my worker I cache the result set and check whether I can process it. If so I process it right away, if not I start a timer to check if I can process it a short time later.

In both my worker $completed code and timer $timerproc code I call the following functions that I've placed in a code class:
cCode/ERR
  ; Bypass error handler, we ignore any errors while this is active
  SEA continue execution

cCode/CanRunTimer(pvExpectedLineCount = 2, pvSendToTraceLog = kFalse)
  Begin reversible block
    ;  Temporarily replace our error handler with a dummy one, sys(192) tends to send bogus issues to our error handler..
    Load error handler cCode/ERR
  End reversible block

  Calculate lvList as sys(192)
  If lvList.$linecount<=pvExpectedLineCount
    ;  We can run it, just $event/$completed/$timerproc and CanRunTimer are on the stack
    Quit method kTrue
  Else If pos('Enter data',lvList.[pvExpectedLineCount+1].linetext)=1
    ;  We can run it, we've interupted an enter data!
    Quit method kTrue
  Else If pos('ALLOW TIMER',lvList.[pvExpectedLineCount+1].linetext)<>0
    ;  Our comment says we are allowed to interupt here!!
    Quit method kTrue
  End If

  ;  !BAS! Timer can interupt END FOR, END WHILE, and some other situation. We DON'T want this as we may be in the middle of processing stuff!
  If pvSendtoTraceLog
    Calculate lvList.$line as pvExpectedLineCount+1
    Send to trace log (Diagnostic message) {Can't run queued timer, stackcount = [lvList.$linecount], interupts [lvList.classitem().$name].[lvList.method]/[lvList.line] = [lvList.linetext]}
  End If
  Quit method kFalse
This method will return true if I can safely process my timer code, and false if I should delay the action.

There are only 3 situations I deem safe:
- if all that is on the stack is my $event, $completed or $timerproc method and my CanRunTimer
- if we are interrupting an "enter data" command
- if we are interrupting a command that contains the "ALLOW TIMER" text (which I can put in a comment)

I've also made it optional to sent any other situation as a diagnostic message to the trace log. If a timer seemingly never runs I can check if I'm being too strict in letting timers run their course.

A timer method would then look something like this:
Timer.$event
  On evTimer
    Do code method cCode/CanRunTimer Returns lvCanRun
    If lvCanRun
      ;  Do your stuff
      ...
    Else
      ;  Restart our timer (omit this if you have $autoreset turned on)
      Calculate $cinst.$objs.Timer.$timervalue as 200 
      Do $cinst.$objs.Timer.$starttimer()
    End If

One last note on the subject. My workers tent to start a timer that delays processing of the results for 1ms regardless of any other running code. First of it means that I only need to implement this check in one place but more importantly it allows $completed to finish and clean up. Workers will also crash Omnis if you start another worker or do any other form of communication with the database even on completely unrelated session objects. By allowing $completed to finish and handling the result set when the timer object fires you gain a lot of stability in your application.

No comments:

Post a Comment