diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..4cab939 --- /dev/null +++ b/.classpath @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.gitignore b/.gitignore index 32858aa..8b6f86a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* +/.exploded/ diff --git a/.project b/.project new file mode 100644 index 0000000..75d6b6a --- /dev/null +++ b/.project @@ -0,0 +1,27 @@ + + + Chime + + + + + + org.eclipse.jdt.core.javabuilder + + + + + com.redhat.ceylon.eclipse.ui.ceylonBuilder + + + systemRepo + + + + + + + com.redhat.ceylon.eclipse.ui.ceylonNature + org.eclipse.jdt.core.javanature + + diff --git a/README.md b/README.md index 48dd7f4..4b0670a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,20 @@ -# Chime -Time scheduler for Vert.x +## Chime + +_Chime_ is time scheduler which works on _Vert.x_ event bus and provides: + +* scheduling with _cron-style_ and _interval_ timers +* applying time zones available on _JVM_ + +>Runs with Ceylon 1.2.0 and Vert.x 3.2.1 + + +## Dependences + +* ceylon.language/1.2.0 +* ceylon.time/1.2.0 +* io.vertx.ceylon.core/3.2.1 + + +## Documentation + + diff --git a/examples/herd/examples/schedule/chime/module.ceylon b/examples/herd/examples/schedule/chime/module.ceylon new file mode 100644 index 0000000..d539bb6 --- /dev/null +++ b/examples/herd/examples/schedule/chime/module.ceylon @@ -0,0 +1,4 @@ +native("jvm") +module herd.examples.schedule.chime "0.1.0" { + shared import io.vertx.ceylon.core "3.2.1"; +} diff --git a/examples/herd/examples/schedule/chime/package.ceylon b/examples/herd/examples/schedule/chime/package.ceylon new file mode 100644 index 0000000..781d781 --- /dev/null +++ b/examples/herd/examples/schedule/chime/package.ceylon @@ -0,0 +1 @@ +shared package herd.examples.schedule.chime; diff --git a/examples/herd/examples/schedule/chime/run.ceylon b/examples/herd/examples/schedule/chime/run.ceylon new file mode 100644 index 0000000..c8d9267 --- /dev/null +++ b/examples/herd/examples/schedule/chime/run.ceylon @@ -0,0 +1,111 @@ +import io.vertx.ceylon.core.eventbus { + + EventBus, + Message +} +import ceylon.json { + + JSON=Object +} +import io.vertx.ceylon.core { + + Vertx, + vertx +} + + +"Runs the module `herd.examples.schedule.chime`." +shared void run() { + value v = vertx.vertx(); + v.deployVerticle ( + "ceylon:herd.schedule.chime/0.1.0", + ( String|Throwable res ) { + if ( is String res ) { + value scheduler = Scheduler( v ); + scheduler.initialize(); + } + else { + print( "deploying error ``res``"); + } + } + ); +} + + +"Performs scheduler run. Creates cron-style timer and listens it. + " +class Scheduler( Vertx v, String address = "chime" ) +{ + EventBus eventBus = v.eventBus(); + + + "Initializes testing - creates schedule manager and timer." + shared void initialize() { + eventBus.send ( + address, + JSON { + "operation" -> "create", + "name" -> "schedule manager", + "state" -> "running" + }, + ( Throwable | Message msg ) { + if ( is Message msg ) { + managerCreated( msg ); + } + else { + print( "error in onConnect ``msg``" ); + v.close(); + } + } + ); + } + + + void printMessage( Throwable | Message msg ) { + if ( is Message msg ) { + if ( exists body = msg.body() ) { + print( body ); + if ( is String state = body.get( "state" ), state == "completed" ) { + v.close(); + } + } + else { + print( "no body in the message" ); + v.close(); + } + } + else { + print( "error: ``msg``" ); + } + } + + + void managerCreated( Message msg ) { + + eventBus.consumer( "schedule manager:timer", printMessage ); + + eventBus.send( + address, + JSON { + "operation" -> "create", + "name" -> "schedule manager:timer", + "state" -> "running", + "publish" -> false, + "max count" -> 3, + "time zone" -> "Europe/Paris", + "descirption" -> JSON { + "type" -> "cron", + "seconds" -> "27/30", + "minutes" -> "*", + "hours" -> "0-23", + "days of month" -> "1-31", + "months" -> "*", + "days of week" -> "*", + "years" -> "2015-2019" + } + }, + printMessage + ); + } + +} \ No newline at end of file diff --git a/source/herd/schedule/chime/ChimeScheduler.ceylon b/source/herd/schedule/chime/ChimeScheduler.ceylon new file mode 100644 index 0000000..dc3d8d0 --- /dev/null +++ b/source/herd/schedule/chime/ChimeScheduler.ceylon @@ -0,0 +1,70 @@ +import io.vertx.ceylon.core { + + Verticle +} +import ceylon.json { + + JSON = Object +} +import herd.schedule.chime.timer { + + definitions, + TimerFactory, + StandardTimerFactory +} + + +"Chime scheduler verticle. Starts scheduling." +by( "Lis" ) +shared class ChimeScheduler() extends Verticle() +{ + + "Scheduler manager." + variable SchedulerManager? scheduler = null; + + "Address to listen on event buss." + variable String address = definitions.defaultAddress; + + "Max year limitation." + variable Integer maxYearPeriod = 10; + + "Tolerance to compare fire time and current time in milliseconds." + variable Integer tolerance = 10; + + "Factory to create timers - refine to produce timers of nonstandard types. + Standard factory creates cron-like timer and interval timer." + TimerFactory timerFactory = StandardTimerFactory( maxYearPeriod ).initialize(); + + + "Reads configuration from json." + void readConfiguration( "Configuration in JSON format." JSON config ) { + // read listening address + if ( is String addr = config.get( definitions.address ) ) { + address = addr; + } + // year period limitation + if ( is Integer maxYear = config.get ( definitions.maxYearPeriodLimit ) ) { + if ( maxYear < 100 ) { maxYearPeriod = maxYear; } + else { maxYearPeriod = 100; } + } + // tolerance to compare times + if ( is Integer tol = config.get ( definitions.tolerance ) ) { + if ( tol > 0 ) { tolerance = tol; } + } + } + + + "Starts _Chime_. Called by Vert.x during deployement." + shared actual void start() { + // read configuration + if ( exists c = config ) { + readConfiguration( c ); + } + + // create scheduler + SchedulerManager sch = SchedulerManager( vertx, vertx.eventBus(), timerFactory, tolerance ); + scheduler = sch; + sch.connect( address ); + } + +} diff --git a/source/herd/schedule/chime/Operator.ceylon b/source/herd/schedule/chime/Operator.ceylon new file mode 100644 index 0000000..e9c5d76 --- /dev/null +++ b/source/herd/schedule/chime/Operator.ceylon @@ -0,0 +1,98 @@ +import ceylon.json { + + JSON=Object +} +import io.vertx.ceylon.core.eventbus { + + Message, + EventBus +} +import herd.schedule.chime.timer { + definitions +} + + +"Provides basic operations with [[JSON]] message." +see( `class SchedulerManager`, `class TimeScheduler` ) +by( "Lis" ) +abstract class Operator( "EventBus to pass messages." shared EventBus eventBus ) +{ + + "Operators map." + variable Map)>? operators = null; + + "creates operators map." + shared formal Map)> createOperators(); + + + "Returns operator by operation code." + shared Anything(Message)? getOperator( "operation code" String code ) { + if ( !operators exists ) { + // create operators map if doesn't exists + operators = createOperators(); + } + return operators?.get( code ); + } + + "Responds on message. + respond format: + { + response -> String // response code + error -> String // error description + name -> String // item name + state -> String // item state + description -> JSON // item description + } + " + shared void respondMessage( "Message to respond on." Message msg, "Rreply to be send" JSON reply ) { + reply.put( definitions.fieldResponse, definitions.responseOK ); + msg.reply( reply ); + } + + "Fails message with message." + shared void failMessage ( + "Message to be responded with failure." Message msg, + "Error to fail with." String errorMessage ) + { + msg.reply ( + JSON { + definitions.fieldResponse -> definitions.responseError, + definitions.fieldError -> errorMessage + } + ); + } + + "Extracts state from request, helper method." + shared TimerState? extractState( JSON request ) { + if ( is String state = request.get( definitions.fieldState ) ) { + return timerRunning.byName( state ); + } + else { + return null; + } + } + + "Message has been received from event bus - process it!." + void onMessage( "Message from event bus." Message msg ) { + if ( exists request = msg.body(), is String operation = request.get( definitions.fieldOperation ) ) { + // depending on operation code + if ( exists operator = getOperator( operation ) ) { + operator( msg ); + } + else { + failMessage( msg, errorMessages.unsupportedOperation ); + } + } + else { + // response with wrong format error + failMessage( msg, errorMessages.operationIsNotSpecified ); + } + } + + "Connects to event bus, returns promise resolved when event listener registered." + shared default void connect( "Address to listen to." String address ) { + // setup event bus listener + eventBus.consumer( address, onMessage ); + } + +} diff --git a/source/herd/schedule/chime/SchedulerManager.ceylon b/source/herd/schedule/chime/SchedulerManager.ceylon new file mode 100644 index 0000000..5d9e887 --- /dev/null +++ b/source/herd/schedule/chime/SchedulerManager.ceylon @@ -0,0 +1,270 @@ +import io.vertx.ceylon.core { + + Vertx +} +import io.vertx.ceylon.core.eventbus { + + Message, + EventBus +} +import ceylon.json { + + JSON=Object, + JSONArray=Array +} +import ceylon.collection { + + HashMap +} +import herd.schedule.chime.timer { + TimerFactory, + definitions +} + + +"manages shcedulers - [[TimeScheduler]]: + * creates + * deletes + * starts + * pauses + * schedulers info + + Instances of the class are used internaly by Chime. + All operations performed as response on request send to general Chime address, \"chime\" by default. + Or specified in configuration file. + Scheduler to be created before any operations with timers requested. + + ### Requesting: + + expects messages in `JSON` format: + { + \"operation\" -> String // operation code, mandatory + \"name\" -> String // scheduler or full timer (\"scheduler name:timer name\") name, mandatory + \"state\" -> String // state, mandatory only for state operation + } + + If timer name specified as *\"scheduler name:timer name\"* operation is performed for timer - + see description in [[TimeScheduler]] otherwise for scheduler - see description below. + + #### operation codes: + * \"create\" - create new scheduler with specified name, state and description, if state is not specified, sceduler to be run. + If full timer name specified *`scheduler name`:`timer name`* timer is to be created, if no scheduler with \"scheduler name\" + has been created before, it will be created. + * \"delete\" - delete scheduler with name `name` (or timer if full timer name specified *\"scheduler name:timer name\"*) + * \"info\" - requesting info on Chime, specific scheduler (scheduler name to be provided) or + timer (full timer name specified *\"scheduler name:timer name\"* to be provided) + * \"state\": + * if is \"get\" state is to be returned + * if is \"running\" scheduler is to be run if not already + * if is \"paused\" scheduler is to be paused if not already + * otherwise error is returned + + #### examples: + // create new scheduler with name \"scheduler name\" + JSON message = JSON { + \"operation\" -> \"create\", + \"name\" -> \"scheduler name\" + } + + // change state of scheduler with \"scheduler name\" on paused + JSON message = JSON { + \"operation\" -> \"state\", + \"name\" -> \"scheduler name\", + \"state\" -> \"paused\" + } + + ### Response + response on messages is in `JSON`: + { + \"response\" -> String // response code - one of \"ok\" or \"error\" + \"name\" -> String // scheduler or full timer (\"scheduler name:timer name\") name + \"state\" -> String // scheduler state + \"schedulers\" -> JSONArray // scheduler names, exists as response on \"info\" operation with no \"name\" field + \"error\" -> String // error description, exists only if response == \"error\" + } + + " +by( "Lis" ) +see(`class TimeScheduler`) +class SchedulerManager( + "Vetrx the scheduler is running on." Vertx vertx, + "Event bus used to dispatch messages." EventBus eventBus, + "Factory to create timers" TimerFactory factory, + "Tolerance to compare fire time and current time in miliseconds." Integer tolerance +) + extends Operator( eventBus ) +{ + + "Time schedulers." + HashMap schedulers = HashMap(); + + TimerCreator creator = TimerCreator( factory ); + + "Returns scheduler by its name or `null` if doesn't exist." + shared TimeScheduler? getScheduler( "Name of the scheduler looked for." String name ) => schedulers.get( name ); + + "Adds new scheduler. + Retruns new or already existed shceduler with name `name`." + shared TimeScheduler addScheduler( "Scheduler name." String name, "Scheduler state." TimerState state ) { + if ( exists sch = getScheduler( name ) ) { + return sch; + } + else { + TimeScheduler sch = TimeScheduler( name, vertx, eventBus, creator, tolerance ); + schedulers.put( name, sch ); + sch.connect( name ); + if ( state == timerRunning ) { + sch.start(); + } + return sch; + } + } + + +// operation methods + + "Creates operators map" + shared actual Map)> createOperators() + => map)> { + definitions.opCreate -> operationCreate, + definitions.opDelete -> operationDelete, + definitions.opState -> operationState, + definitions.opInfo -> operationInfo + }; + + "Processes 'create new scheduler' operation." + void operationCreate( Message msg ) { + if ( exists request = msg.body(), is String name = request.get( definitions.fieldName ) ) { + + String schedulerNamer; + String timerName; + if ( exists inc = name.firstInclusion( definitions.nameSeparator ) ) { + schedulerNamer = name.spanTo( inc - 1 ); + timerName = name; + } + else { + schedulerNamer = name; + timerName = ""; + } + value scheduler = addScheduler( schedulerNamer, extractState( request ) else timerRunning ); + if ( timerName.empty ) { + failMessage( msg, errorMessages.timerAlreadyExists ); + } + else { + // add timer to scheduler + scheduler.operationCreate( msg ); + } + } + else { + // response with wrong format error + failMessage( msg, errorMessages.schedulerNameHasToBeSpecified ); + } + } + + "Processes 'delete scheduler' operation." + void operationDelete( Message msg ) { + if ( exists request = msg.body(), is String name = request.get( definitions.fieldName ) ) { + // delete scheduler + if ( exists sch = schedulers.remove( name ) ) { + sch.stop(); + // scheduler successfully removed + respondMessage( msg, sch.shortInfo ); + } + else { + // scheduler doesn't exists - look if name is full timer name + value schedulerName = name.spanTo( ( name.firstInclusion( definitions.nameSeparator ) else 0 ) - 1 ); + if ( !schedulerName.empty, exists sch = schedulers.get( schedulerName ) ) { + // scheduler has to remove timer + sch.operationDelete( msg ); + } + else { + // scheduler or timer doesn't exist + failMessage( msg, errorMessages.schedulerNotExists ); + } + } + } + else { + // response with wrong format error + failMessage( msg, errorMessages.schedulerNameHasToBeSpecified ); + } + } + + "Processes 'scheduler state' operation." + void operationState( Message msg ) { + if ( exists request = msg.body(), is String name = request.get( definitions.fieldName ) ) { + if ( is String state = request.get( definitions.fieldState ) ) { + if ( exists sch = schedulers.get( name ) ) { + if ( state == definitions.stateGet ) { + // return state + respondMessage( msg, sch.shortInfo ); + } + else if ( state == timerPaused.string ){ + // set paused state + sch.pause(); + respondMessage( msg, sch.shortInfo ); + } + else if ( state == timerRunning.string ){ + // set running state + sch.start(); + respondMessage( msg, sch.shortInfo ); + } + else { + // state to be one of - get, paused, running + failMessage( msg, errorMessages.incorrectTimerState ); + } + } + else { + // scheduler doesn't exists - look if name is full timer name + value schedulerName = name.spanTo( ( name.firstInclusion( definitions.nameSeparator ) else 0 ) - 1 ); + if ( !schedulerName.empty, exists sch = schedulers.get( schedulerName ) ) { + // scheduler has to provide timer state + sch.operationState( msg ); + } + else { + // scheduler or timer doesn't exist + failMessage( msg, errorMessages.schedulerNotExists ); + } + } + } + else { + // scheduler state to be specified + failMessage( msg, errorMessages.stateToBeSpecified ); + } + } + else { + // scheduler name to be specified + failMessage( msg, errorMessages.schedulerNameHasToBeSpecified); + } + } + + "Replies with Chime info - array of scheduler names." + void operationInfo( Message msg ) { + if ( is String name = msg.body()?.get( definitions.fieldName ) ) { + if ( exists sch = schedulers.get( name ) ) { + // reply with scheduler info + msg.reply( sch.fullInfo ); + } + else { + // scheduler doesn't exists - look if name is full timer name + value schedulerName = name.spanTo( ( name.firstInclusion( definitions.nameSeparator ) else 0 ) - 1 ); + if ( !schedulerName.empty, exists sch = schedulers.get( schedulerName ) ) { + // scheduler has to reply for timer info + sch.operationInfo( msg ); + } + else { + // scheduler or timer doesn't exist + failMessage( msg, errorMessages.schedulerNotExists ); + } + } + } + else { + msg.reply ( + JSON { + definitions.fieldResponse -> definitions.responseOK, + definitions.fieldSchedulers -> JSONArray( { for ( scheduler in schedulers.items ) scheduler.name } ) + } + ); + } + } + +} \ No newline at end of file diff --git a/source/herd/schedule/chime/TimeConverter.ceylon b/source/herd/schedule/chime/TimeConverter.ceylon new file mode 100644 index 0000000..668b4ae --- /dev/null +++ b/source/herd/schedule/chime/TimeConverter.ceylon @@ -0,0 +1,90 @@ +import ceylon.time { + + DateTime, + Instant +} +import java.util { + + JavaTimeZone=TimeZone +} +import ceylon.json { + + JSON=Object +} +import herd.schedule.chime.timer { + + definitions +} + + +"Converting date-time according to rule (timezone)." +by( "Lis" ) +interface TimeConverter { + + "Converts remote date-time to local one." + shared formal DateTime toLocal( "Date-time to convert from." DateTime remote ); + + "Converts local date-time to remote one." + shared formal DateTime toRemote( "Date-time to convert from." DateTime local ); + + "Returns time zone id." + shared formal String timeZoneID; +} + + +"Defines time converter which do no convertion." +by( "Lis" ) +object dummyConverter satisfies TimeConverter { + + "Local time zone." + shared JavaTimeZone local = JavaTimeZone.default; + + "Returns converter by time zone name." + shared TimeConverter? getConverter( JSON description ) { + if ( is String timeZoneID = description.get( definitions.timeZoneID ) ) { + JavaTimeZone tz = JavaTimeZone.getTimeZone( timeZoneID ); + if ( tz.id == timeZoneID ) { + return ConverterWithTimezone( tz ); + } + else { + return null; + } + } + else { + return dummyConverter; + } + } + + "Returns `remote`." + shared actual DateTime toLocal( DateTime remote ) => remote; + + "Returns `local`." + shared actual DateTime toRemote( DateTime local ) => local; + + shared actual String timeZoneID => local.id; + +} + + +"Converts according to specified remote time zone." +by( "Lis" ) +class ConverterWithTimezone( JavaTimeZone remoteTimeZone ) satisfies TimeConverter { + + + shared actual DateTime toLocal( DateTime remote ) { + Integer remoteTime = remote.instant().millisecondsOfEpoch; + Integer utcTime = remoteTime - remoteTimeZone.getOffset( remoteTime ); + Integer localUTCOffset = dummyConverter.local.getOffset( utcTime + dummyConverter.local.getOffset( utcTime ) ); + return Instant( utcTime + localUTCOffset ).dateTime(); + } + + shared actual DateTime toRemote( DateTime local ) { + Integer localTime = local.instant().millisecondsOfEpoch; + Integer utcTime = localTime - dummyConverter.local.getOffset( localTime ); + Integer remoteUTCOffset = remoteTimeZone.getOffset( utcTime + remoteTimeZone.getOffset( utcTime ) ); + return Instant( utcTime + remoteUTCOffset ).dateTime(); + } + + shared actual String timeZoneID => remoteTimeZone.id; + +} diff --git a/source/herd/schedule/chime/TimeScheduler.ceylon b/source/herd/schedule/chime/TimeScheduler.ceylon new file mode 100644 index 0000000..f40c01f --- /dev/null +++ b/source/herd/schedule/chime/TimeScheduler.ceylon @@ -0,0 +1,521 @@ +import io.vertx.ceylon.core { + + Vertx +} +import io.vertx.ceylon.core.eventbus { + + Message, + EventBus +} +import ceylon.json { + + JSON=Object, + JSONArray=Array +} +import ceylon.collection { + + HashMap +} +import ceylon.time { + + systemTime, + Period, + zero, + DateTime +} +import ceylon.time.timezone { + + timeZone +} +import herd.schedule.chime.timer { + definitions +} +import herd.schedule.chime.cron { + + calendar +} + + +"Scheduler - listen address of scheduler [[name]] on event bus and manages timers. + This class is used internaly by Chime + + ### Requests + + Requests are send in [[JSON]] format on scheduler name address + { + \"operation\" -> String // operation code, mandatory + \"name\" -> String // timer short or full name, mandatory + \"state\" -> String // state, nonmandatory, except sate operation + + // fields for create operation: + \"maximum count\" -> Integer // maximum number of fires, default - unlimited + \"publish\" -> Boolean // if true message to be published and send otherwise, nonmandatory + + \"start time\" -> `JSON` // start time, nonmadatory, if doesn't exists timer will start immediately + { + \"seconds\" -> Integer // seconds, mandatory + \"minutes\" -> Integer // minutes, mandatory + \"hours\" -> Integer // hours, mandatory + \"day of month\" -> Integer // days of month, mandatory + \"month\" -> Integer or String // months, if string - short name, mandatory + \"year\" -> Integer // year, Integer, mandatory + } + + \"end time\" -> `JSON` // end time, nonmadatory, default no end time + { + \"seconds\" -> Integer // seconds, mandatory + \"minutes\" -> Integer // minutes, mandatory + \"hours\" -> Integer // hours, mandatory + \"day of month\" -> Integer // days of month, mandatory + \"month\" -> Integer or String // months, if string - short name, mandatory + \"year\" -> Integer // year, Integer, mandatory + } + + \"time zone\" -> String // time zone name, nonmandatory, default server local + + \"description\" -> JSON // timer desciption, mandatoty for create operation + } + + timer full name is *'scheduler name':'timer short name'* + + #### operation codes: + * \"create\" - create new timer with specified name, state and description + * \"delete\" - delete timer with name `name` + * \"info\" - get information on timer (if timer name is specified) or scheduler (if timer name is not specified) + * \"state\": + * if state field is \"get\" state is to be returned + * if state field is \"running\" timer is to be run if not already + * if state field is \"paused\" timer is to be paused if not already + * otherwise error is returned + + #### supported timers (types): + * cron style, defined like cron, but with some simplifications + * incremental, fires after each specified period (minimum 1 second) + + #### timer description in depends on the timer type: + + * cron style timer description: + { + \"type\" -> String // timer type, mandatory + + \"seconds\" -> String // seconds in cron style, mandatory + \"minutes\" -> String // minutes in cron style, mandatory + \"hours\" -> String // hours in cron style, mandatory + \"day of month\" -> String // days of month in cron style, mandatory + \"month\" -> String // months in cron style, mandatory + \"day of week\" -> String // days of week in cron style, L means last, # means nth of month, nonmandatory + \"year\" -> String // year in cron style, nonmandatory + } + + * interval timer description: + { + \"type\" -> String // timer type, mandatory + \"increment seconds\" -> Integer // increment in seconds, if <= 0 timer fires only once, mandatory + } + + + ### Response. + + Scheduler responses on each request in [[JSON]] format: + { + \"response\" -> String // response code - one of `ok` or `error` + \"name\" -> String // timer name + \"state\" -> String // state + + \"error\" -> String // error description, exists only if response == `error` + \"timers\" -> JSONArray // list of timer names currently scheduled - response on info operation with no name field specified + + // Info operation returns fields from create operation also + } + + " +by( "Lis" ) +class TimeScheduler( + "Scheduler name." shared String name, + "Vertx the scheduler operates on." Vertx vertx, + "EventBus to pass messages." EventBus eventBus, + "Factory to create timers." TimerCreator factory, + "Tolerance to compare fire time and current time in miliseconds." Integer tolerance + ) + extends Operator( eventBus ) +{ + + "Tolerance to compare fire time." + variable Period tolerancePeriod = zero.plusMilliseconds( tolerance ); + + "Timers sorted by next fire time." + HashMap timers = HashMap(); + + "Id of vertx timer." + variable Integer? timerID = null; + + "Value for scheduler state - running, paused or completed." + variable TimerState schedulerState = timerPaused; + + "Scheduler state - running, paused or completed." + shared TimerState state => schedulerState; + + "Scheduler `JSON` short info (name and state)." + shared JSON shortInfo + => JSON { + definitions.fieldName -> name, + definitions.fieldState -> schedulerState.string + }; + + "Scheduler full info in `JSON`: + \"response\" -> ok, + \"name\" -> scheduler name, + \"state\" -> scheduler state + \"timers\" -> array of timer names + " + shared JSON fullInfo => JSON { + definitions.fieldResponse -> definitions.responseOK, + definitions.fieldName -> name, + definitions.fieldState -> state.string, + definitions.fieldTimers -> JSONArray( { for ( timer in timers.items ) timer.name } ) + }; + + +// timers map methods + + "`true` if timer is running." + Boolean selectRunning( TimerContainer timer ) => timer.state == timerRunning; + + "`true` if timer is completed." + Boolean selectCompleted( TimerContainer timer ) => timer.state == timerCompleted; + + "`true` if timer is running or paused." + Boolean selectIncompleted( TimerContainer timer ) => timer.state != timerCompleted; + + "Name of the given timer." + String timerName( TimerContainer timer ) => timer.name; + + "Remove completed timers." + void removeCompleted() => timers.removeAll( timers.items.filter( selectCompleted ).map( timerName ) ); + + + "Minimum timer delay." + Integer minDelay() { + DateTime current = localTime(); + variable Integer delay = 0; + for ( timer in timers.items.filter( selectRunning ) ) { + if ( exists localDate = timer.localFireTime ) { + Integer offset = localDate.offset( current ); + if ( offset <= 0 ) { + if ( delay > 500 || delay == 0 ) { + delay = 500; + } + } + else if ( offset < delay || delay == 0 ) { + delay = offset; + } + } + } + return delay; + } + + "Current local time." + DateTime localTime() => systemTime.instant().dateTime( timeZone.system ); + + "Fire timers, returns `true` if some timer has been fired and `false` if no one timer has been fired." + void fireTimers() { + variable Boolean completed = false; + DateTime current = localTime().plus( tolerancePeriod ); + for ( timer in timers.items.filter( selectRunning ) ) { + if ( exists localDate = timer.localFireTime, exists remoteDate = timer.remoteFireTime ) { + if ( localDate < current ) { + timer.shiftTime(); + sendTimerMessage( timer, remoteDate ); + if ( timer.state == timerCompleted ) { + sendTimerMessage( timer ); + completed = true; + } + } + } + } + if ( completed ) { + removeCompleted(); + } + } + + "Sends fire or completed message in standard Chime format. + + message format: + { + \"name\": timer name, String + \"time\": String formated time / date from [[TimerContainer.remoteFireTime]] or nothing if not specified + \"count\": total number of fire times + \"state\": String representation of [[TimerContainer.state]] + } + " + shared void sendTimerMessage( TimerContainer timer, DateTime? date = null ) { + JSON message; + + // date string + if ( exists date ) { + message = JSON { + definitions.fieldName -> timer.name, + definitions.fieldTime -> date.string, + definitions.fieldCount -> timer.count, + definitions.fieldState -> timer.state.string, + calendar.seconds -> date.seconds, + calendar.minutes -> date.minutes, + calendar.hours -> date.hours, + calendar.dayOfMonth -> date.day, + calendar.month -> date.month.integer, + calendar.year -> date.year, + definitions.timeZoneID -> timer.timeZoneID + }; + } + else { + message = JSON { + definitions.fieldName -> timer.name, + definitions.fieldCount -> timer.count, + definitions.fieldState -> timer.state.string + }; + } + + // send message + if ( timer.publish ) { + eventBus.publish( timer.name, message ); + } + else { + eventBus.send( timer.name, message ); + } + } + + +// vertx timer + + "Cancels current vertx timer." + void cancelCurrentVertxTimer() { + if ( exists id = timerID ) { + timerID = null; + vertx.cancelTimer( id ); + } + } + + "Builds vertx timer using min delay." + void buildVertxTimer() { + cancelCurrentVertxTimer(); + Integer delay = minDelay(); + if ( delay > 0 ) { + timerID = vertx.setTimer( delay, vertxTimerFired ); + } + } + + "Vertx timer has been fired - send message and use next vertx timer." + void vertxTimerFired( Integer id ) { + if ( state == timerRunning, exists currentID = timerID, currentID == id ) { + timerID = null; + fireTimers(); + buildVertxTimer(); + } + } + + +// operations + + "Returns timer full name from short name." + String timerFullName( String timerShortName ) { + if ( timerShortName.startsWith( name + definitions.nameSeparator ) ) { + return timerShortName; + } + else { + return name + definitions.nameSeparator + timerShortName; + } + } + + "Adds timer to timers - to be added according to next fire time sort. + If timer has been added previously it will be replaced." + void addTimer ( + "timer to be added" TimerContainer timer, + "timer state" TimerState state ) + { + if ( state == timerRunning ) { + timer.start( localTime() ); + if ( timer.state == timerRunning ) { + timers.put( timer.name, timer ); + buildVertxTimer(); + } + else { + sendTimerMessage( timer ); + } + } + else if ( state == timerPaused ) { + timers.put( timer.name, timer ); + } + } + + + "Creates operators map." + shared actual Map)> createOperators() + => map)> { + definitions.opCreate -> operationCreate, + definitions.opDelete -> operationDelete, + definitions.opState -> operationState, + definitions.opInfo -> operationInfo + }; + + "Creates new timer." + shared void operationCreate( Message msg ) { + if ( exists request = msg.body(), is String tName = request.get( definitions.fieldName ) ) { + String timerName = timerFullName( tName ); + if ( timers.defines( timerName ) ) { + // timer already exists + failMessage( msg, errorMessages.timerAlreadyExists ); + } + else { + value timer = factory.createTimer( timerFullName( tName ), request ); + if ( is TimerContainer timer ) { + addTimer( timer, extractState( request ) else timerRunning ); + // timer successfully added + respondMessage( msg, timer.stateDescription() ); + } + else { + // wrong description + failMessage( msg, timer ); + } + } + } + else { + // timer name to be specified + failMessage( msg, errorMessages.timerNameHasToBeSpecified ); + } + + } + + "Deletes existing timer." + shared void operationDelete( Message msg ) { + if ( exists request = msg.body(), is String tName = request.get( definitions.fieldName ) ) { + String timerName = timerFullName( tName ); + // delete timer + if ( exists t = timers.remove( timerName ) ) { + t.complete(); + // timer successfully removed + respondMessage( msg, t.stateDescription() ); + } + else { + // timer doesn't exist + failMessage( msg, errorMessages.timerNotExists ); + } + } + else { + // timer name to be specified + failMessage( msg, errorMessages.timerNameHasToBeSpecified ); + } + } + + "Processes 'timer state' operation." + shared void operationState( Message msg ) { + if ( exists request = msg.body(), is String tName = request.get( definitions.fieldName ) ) { + if ( is String state = request.get( definitions.fieldState ) ) { + String timerName = timerFullName( tName ); + if ( exists t = timers.get( timerName ) ) { + if ( state == definitions.stateGet ) { + // return state + respondMessage( msg, t.stateDescription() ); + } + else if ( state == timerPaused.string ){ + // set paused state + t.state = timerPaused; + respondMessage( msg, t.stateDescription() ); + } + else if ( state == timerRunning.string ) { + // set running state + if ( t.state == timerPaused ) { + t.start( localTime() ); + if ( t.state == timerRunning ) { + buildVertxTimer(); + } + else { + timers.remove( t.name ); + sendTimerMessage( t ); + } + } + respondMessage( msg, t.stateDescription() ); + } + else { + // state to be one of - get, paused, running + failMessage( msg, errorMessages.incorrectTimerState ); + } + } + else { + // timer doesn't exist + failMessage( msg, errorMessages.timerNotExists ); + } + } + else { + // timer state to be specified + failMessage( msg, errorMessages.stateToBeSpecified ); + } + } + else { + // timer name to be specified + failMessage( msg, "timer name has to be specified" ); + } + } + + "Replies with scheduler info - array of timer names." + shared void operationInfo( Message msg ) { + if ( exists request = msg.body(), is String tName = request.get( definitions.fieldName ) ) { + // contains name field - reply with info about timer with specified name + String timerName = timerFullName( tName ); + if ( exists t = timers.get( timerName ) ) { + // timer successfully removed + respondMessage( msg, t.fullDescription() ); + } + else { + // timer doesn't exist + failMessage( msg, errorMessages.timerNotExists ); + } + } + else { + // timer name to be specified + failMessage( msg, errorMessages.timerNameHasToBeSpecified ); + } + } + + +// scheduler methods + + "Starts scheduling." + see( `function pause` ) + see( `function stop` ) + shared void start() { + if ( state != timerRunning ) { + schedulerState = timerRunning; + DateTime current = localTime(); + for ( timer in timers.items.filter( selectRunning ) ) { + timer.start( current ); + if ( timer.state == timerCompleted ) { + sendTimerMessage( timer ); + } + } + removeCompleted(); + buildVertxTimer(); + } + } + + "Pauses scheduling - all fires to be missed while start not called." + see( `function start` ) + shared void pause() { + schedulerState = timerPaused; + cancelCurrentVertxTimer(); + } + + "Completes all timers and terminates this scheduler." + shared void stop() { + schedulerState = timerCompleted; + + // fire completed on all timers + for ( timer in timers.items.filter( selectIncompleted ) ) { + timer.complete(); + sendTimerMessage( timer ); + } + timers.clear(); + + cancelCurrentVertxTimer(); + } + +} diff --git a/source/herd/schedule/chime/TimerContainer.ceylon b/source/herd/schedule/chime/TimerContainer.ceylon new file mode 100644 index 0000000..3054b28 --- /dev/null +++ b/source/herd/schedule/chime/TimerContainer.ceylon @@ -0,0 +1,179 @@ +import ceylon.json { + + JSON=Object +} +import ceylon.time { + + DateTime +} +import herd.schedule.chime.timer { + definitions, + Timer +} +import herd.schedule.chime.cron { + + calendar +} + + +"Posses timer." +by( "Lis" ) +class TimerContainer ( + "Timer full name, which is *'scheduler name':'timer name'*." shared String name, + "Timer [[JSON]] descirption" shared JSON description, + "`true` if message to be published and `false` if message to be send" shared Boolean publish, + "Timer within this container." Timer timer, + "Remote-local date-time converter." TimeConverter converter, + "Max count or null if not specified." shared Integer? maxCount, + "Timer start time or null if to be started immediately." shared DateTime? startTime, + "Timer end time or null if not specified." shared DateTime? endTime +) { + + "Timer fire counting." + shared variable Integer count = 0; + + "Timer state." + shared variable TimerState state = timerPaused; + + "Next fire timer in remote TZ or null if completed." + variable DateTime? nextRemoteFireTime = null; + + "Next fire timer in remote TZ or null if completed." + shared DateTime? remoteFireTime => nextRemoteFireTime; + + "Next fire timer in remote TZ or null if completed." + shared DateTime? localFireTime => if ( exists d = nextRemoteFireTime ) then converter.toLocal( d ) else null; + + "Time zone ID." + shared String timeZoneID => converter.timeZoneID; + + "Timer name + state." + shared JSON stateDescription() { + return JSON { + definitions.fieldName -> name, + definitions.fieldState -> state.string + }; + } + + "Returns _full_ timer description." + shared JSON fullDescription() { + JSON descr = JSON { + definitions.fieldName -> name, + definitions.fieldState -> state.string, + definitions.fieldCount -> count, + definitions.fieldDescription -> description, + definitions.fieldPublish -> publish + }; + + if ( exists d = maxCount ) { + descr.put( definitions.fieldMaxCount, d ); + } + + if ( exists d = startTime ) { + descr.put ( + definitions.fieldStartTime, + JSON { + calendar.seconds -> d.seconds, + calendar.minutes -> d.minutes, + calendar.hours -> d.hours, + calendar.dayOfMonth -> d.day, + calendar.month -> d.month.string, + calendar.year -> d.year + } + ); + } + + if ( exists d = endTime ) { + descr.put ( + definitions.fieldEndTime, + JSON { + calendar.seconds -> d.seconds, + calendar.minutes -> d.minutes, + calendar.hours -> d.hours, + calendar.dayOfMonth -> d.day, + calendar.month -> d.month.string, + calendar.year -> d.year + } + ); + } + + return descr; + } + + "Starts the timer." + shared void start( DateTime currentLocal ) { + + DateTime currentRemote = converter.toRemote( currentLocal ); + + // check if max count has been reached before + if ( exists c = maxCount ) { + if ( count >= c ) { + complete(); + return; + } + } + + // check if start time is after current + DateTime beginning; + if ( exists st = startTime ) { + if ( st > currentRemote ) { + beginning = st; + } + else { + beginning = currentRemote; + } + } + else { + beginning = currentRemote; + } + + // start timer + if ( exists date = timer.start( beginning ) ) { + if ( exists ed = endTime ) { + if ( date > ed ) { + complete(); + return; + } + } + state = timerRunning; + nextRemoteFireTime = date; + } + else { + complete(); + } + } + + "Sets timer completed." + shared void complete() { + nextRemoteFireTime = null; + state = timerCompleted; + } + + "Shifts timer to the next time." + shared void shiftTime() { + if ( state == timerRunning ) { + count ++; + if ( exists date = timer.shiftTime() ) { + + // check on complete + + if ( exists ed = endTime ) { + if ( date > ed ) { + complete(); + return; + } + } + + if ( exists c = maxCount ) { + if ( count >= c ) { + complete(); + return; + } + } + + nextRemoteFireTime = date; + } + } + } + +} diff --git a/source/herd/schedule/chime/TimerCreator.ceylon b/source/herd/schedule/chime/TimerCreator.ceylon new file mode 100644 index 0000000..544892e --- /dev/null +++ b/source/herd/schedule/chime/TimerCreator.ceylon @@ -0,0 +1,169 @@ +import ceylon.json { + + JSON=Object +} +import ceylon.time { + + dateTime, + DateTime +} +import herd.schedule.chime.timer { + + TimerFactory, + definitions, + Timer +} +import herd.schedule.chime.cron { + + calendar +} + + +"Uses [[JSON]] description to creates [[TimerContainer]] with timer [[Timer]] created by timer factory." +by( "Lis" ) +see( `interface TimerFactory` ) +see( `interface Timer` ) +see( `class TimerContainer` ) +class TimerCreator( "Factory to create timers." TimerFactory factory ) +{ + + "Creates timer from creation request." + shared TimerContainer|String createTimer( "Timer name." String name, "Tequest with timer description." JSON request ) { + if ( is JSON description = request.get( definitions.fieldDescription ) ) { + value timer = factory.createTimer( description ); + if ( is Timer timer ) { + return createTimerContainer( request, description, name, timer ); + } + else { + return timer; + } + } + else { + // timer description to be specified + return errorMessages.timerDescriptionHasToBeSpecified; + } + } + + + "Creates timer container by container and creation request." + TimerContainer|String createTimerContainer ( + "Request on timer creation." JSON request, + "Timer desciption." JSON description, + "Timer name." String name, + "Timer." Timer timer + ) { + // extract start date if exists + DateTime? startDate; + if ( is JSON startTime = request.get( definitions.fieldStartTime ) ) { + if ( exists st = extractDate( startTime ) ) { + startDate = st; + } + else { + return errorMessages.incorrectStartDate; + } + } + else { + startDate = null; + } + + // extract end date if exists + DateTime? endDate; + if ( is JSON endTime = request.get( definitions.fieldEndTime ) ) { + if ( exists st = extractDate( endTime ) ) { + endDate = st; + } + else { + return errorMessages.incorrectEndDate; + } + } + else { + endDate = null; + } + + // end date has to be after start! + if ( exists st = startDate, exists et = endDate ) { + if ( et <= st ) { + return errorMessages.endDateToBeAfterStartDate; + } + } + + if ( exists converter = dummyConverter.getConverter( request ) ) { + return TimerContainer ( + name, description, extractPublish( request ), timer, + converter, extractMaxCount( request ), startDate, endDate + ); + } + else { + return errorMessages.unsupportedTimezone; + } + + } + + "Extracts month from field with key key. The field can be either integer or string (like JAN, FEB etc, see [[calendar]])." + Integer? extractMonth( JSON description, String key ) { + if ( is Integer val = description.get( key ) ) { + if ( val > 0 && val < 13 ) { + return val; + } + else { + return null; + } + } + else if ( is String val = description.get( key ) ) { + if ( exists ret = calendar.monthFullMap.get( val ) ) { + return ret; + } + return calendar.monthShortMap.get( val ); + } + else { + return null; + } + } + + "Extracts date from [[JSON]], key returns [[JSON]] object with date." + DateTime? extractDate( JSON date ) { + if ( is Integer seconds = date.get( calendar.seconds ), + is Integer minutes = date.get( calendar.minutes ), + is Integer hours = date.get( calendar.hours ), + is Integer dayOfMonth = date.get( calendar.dayOfMonth ), + is Integer year = date.get( calendar.year ), + exists month = extractMonth( date, calendar.month ) + ) { + try { + return dateTime( year, month, dayOfMonth, hours, minutes, seconds ); + } + catch ( Throwable err ) { + return null; + } + } + return null; + } + + "Extracts publish field from description. + `publish` or `send` are nonmandatory field. + If no field extracted - default to be send." + Boolean extractPublish( JSON description ) { + if ( is Boolean b = description.get( definitions.fieldPublish ) ) { + return b; + } + else { + return false; + } + } + + "`maxCount` - nonmandatory field, if not specified - infinitely." + Integer? extractMaxCount( JSON description ) { + if ( is Integer c = description.get( definitions.fieldMaxCount ) ) { + if ( c > 0 ) { + return c; + } + else { + return 1; + } + } + else { + return null; + } + } + +} diff --git a/source/herd/schedule/chime/TimerState.ceylon b/source/herd/schedule/chime/TimerState.ceylon new file mode 100644 index 0000000..2d33f39 --- /dev/null +++ b/source/herd/schedule/chime/TimerState.ceylon @@ -0,0 +1,50 @@ + + +"Timer state - running, paused or completed." +by( "Lis" ) +abstract class TimerState() + of timerRunning | timerPaused | timerCompleted +{ + + "Returns timer state by name: + * \"paused\" - timerPaused + * \"running\" - timerRunning + * \"completed\" - timerCompleted + * otherwise - null + " + shared TimerState? byName( String name ) { + if ( name == timerPaused.string ) { + return timerPaused; + } + else if ( name == timerRunning.string ) { + return timerRunning; + } + else if ( name == timerCompleted.string ) { + return timerCompleted; + } + else { + return null; + } + } +} + +"Timer running state." +by( "Lis" ) +object timerRunning extends TimerState() +{ + shared actual String string = "running"; +} + +"Timer paused state." +by( "Lis" ) +object timerPaused extends TimerState() +{ + shared actual String string = "paused"; +} + +"Timer completed state." +by( "Lis" ) +object timerCompleted extends TimerState() +{ + shared actual String string = "completed"; +} diff --git a/source/herd/schedule/chime/cron/CronExpression.ceylon b/source/herd/schedule/chime/cron/CronExpression.ceylon new file mode 100644 index 0000000..6e783f3 --- /dev/null +++ b/source/herd/schedule/chime/cron/CronExpression.ceylon @@ -0,0 +1,13 @@ + +"Cron expression parsed by items." +by( "Lis" ) +shared class CronExpression ( + "Set of exoression seconds, 0-59." shared Set seconds, + "Set of exoression minutes, 0-59." shared Set minutes, + "Set of exoression hours, 0-23." shared Set hours, + "Set of exoression days of month, 1-31." shared Set daysOfMonth, + "Set of exoression months, 1-12." shared Set months, + "Days of week." shared DaysOfWeekList daysOfWeek, + "Set of exoression years, can be empty." shared Set years + ) +{} diff --git a/source/herd/schedule/chime/cron/DaysOrder.ceylon b/source/herd/schedule/chime/cron/DaysOrder.ceylon new file mode 100644 index 0000000..7b8a315 --- /dev/null +++ b/source/herd/schedule/chime/cron/DaysOrder.ceylon @@ -0,0 +1,62 @@ +import ceylon.time { + + Date +} +import ceylon.time.base { + + DayOfWeek +} + + +"Checks order of day of week." +by( "Lis" ) +shared interface DayOrder +{ + "`true` if data falls on a one of ordered day and `false` otherwise." + shared formal Boolean falls( Date date ); +} + + +"Cheks if date falls on a one from day of week list." +by( "Lis" ) +shared class DaysOfWeekList( "set of day order" shared {DayOrder*} orderedDays ) satisfies DayOrder +{ + "`true` if one of ordered days returns `true` and `false` if all of them returns `false`." + shared actual Boolean falls( Date date ) { + for ( item in orderedDays ) { + if ( item.falls( date ) ) { + return true; + } + } + return false; + } +} + + +"All days are accepted." +class DayOrderAll() satisfies DayOrder +{ + shared actual Boolean falls( Date date ) => true; +} + +"Checks if data falls on one of specified day of week." +class DayOrderWeek( "Set of accepted days of week, if empty all days are rejected." shared Set daysOfWeek ) + satisfies DayOrder +{ + shared actual Boolean falls( Date date ) => daysOfWeek.contains( date.dayOfWeek ); +} + +"Checks if date is nth day of week." +class DayOrderNth( "Accepted day of week." shared DayOfWeek day, "'nth' order of day of week." Integer order ) + satisfies DayOrder +{ + shared actual Boolean falls( Date date ) => ( date.dayOfWeek == day ) && ( order == date.day / 7 + 1 ); +} + +"Checks if date is last day of week in the month." +class DayOrderLast( "Accepted day of week." shared DayOfWeek day ) + satisfies DayOrder +{ + shared actual Boolean falls( Date date ) => ( date.dayOfWeek == day ) && ( date.plusDays( 7 ).month != date.month ); +} + diff --git a/source/herd/schedule/chime/cron/calendar.ceylon b/source/herd/schedule/chime/cron/calendar.ceylon new file mode 100644 index 0000000..7fbf33e --- /dev/null +++ b/source/herd/schedule/chime/cron/calendar.ceylon @@ -0,0 +1,101 @@ + + +"Defines calendar constants." +by( "Lis" ) +shared object calendar +{ + + // description fields + + // cron and date fields + shared String seconds = "seconds"; + shared String minutes = "minutes"; + shared String hours = "hours"; + shared String daysOfWeek = "days of week"; + + shared String daysOfMonth = "days of month"; + shared String months = "months"; + shared String years = "years"; + + shared String dayOfMonth = "day of month"; + shared String month = "month"; + shared String year = "year"; + + // name to id maps + + "mapping of month name to month id" + shared Map monthShortMap = + map { + "JAN" -> 1, + "FEB" -> 2, + "MAR" -> 3, + "APR" -> 4, + "MAY" -> 5, + "JUN" -> 6, + "JUL" -> 7, + "AUG" -> 8, + "SEP" -> 9, + "OCT" -> 10, + "NOV" -> 11, + "DEC" -> 12 + }; + + shared Map monthFullMap = + map { + "JANUARY" -> 1, + "FEBRUARY" -> 2, + "MARCH" -> 3, + "APRIL" -> 4, + "MAY" -> 5, + "JUNE" -> 6, + "JULY" -> 7, + "AUGUST" -> 8, + "SEPTEMBER" -> 9, + "OCTOBER" -> 10, + "NOVEMBER" -> 11, + "DECEMBER" -> 12 + }; + + "mapping of day name to day id" + shared Map dayOfWeekShortMap = + map { + "SUN" -> 1, + "MON" -> 2, + "TUE" -> 3, + "WED" -> 4, + "THU" -> 5, + "FRI" -> 6, + "SAT" -> 7 + }; + + "mapping of day name to day id" + shared Map dayOfWeekFullMap = + map { + "SUNDAY" -> 1, + "MONDAY" -> 2, + "TUESDAY" -> 3, + "WEDNESDAY" -> 4, + "THURSDAY" -> 5, + "FRIDAY" -> 6, + "SATURDAY" -> 7 + }; + + + String replaceStringToNumber( String expression, Map map ) { + variable String ret = expression; + for ( key -> item in map ) { + ret = ret.replace( key, item.string ); + } + return ret; + } + + "Replace all occurancies of month names by corresponding number." + shared String replaceMonthByNumber( String expression ) + => replaceStringToNumber( replaceStringToNumber( expression, monthFullMap ), monthShortMap ); + + "Replace all occurancies of weekday names by corresponding number." + shared String replaceDayOfWeekByNumber( String expression ) + => replaceStringToNumber( replaceStringToNumber( expression, dayOfWeekFullMap ), dayOfWeekShortMap ); + + +} diff --git a/source/herd/schedule/chime/cron/cronDefinitions.ceylon b/source/herd/schedule/chime/cron/cronDefinitions.ceylon new file mode 100644 index 0000000..8f4bdc3 --- /dev/null +++ b/source/herd/schedule/chime/cron/cronDefinitions.ceylon @@ -0,0 +1,33 @@ + +"defines some constant used within cron expresion" +by( "Lis" ) +object cron +{ + + // cron special symbols + + "separators" + shared {Character*} separators = { ' ', '\t', '\r', '\n' }; + + "delimiter of fields" + shared Character delimiter = ','; + + "special symbols in a one token" + shared {Character*} special = { '/', '-' }; + + "increments symbol" + shared Character increments = '/'; + + "range symbol" + shared Character range = '-'; + + "all values symbol" + shared Character allValues = '*'; + + "last symbol" + shared Character last = 'L'; + + "nth day symbol" + shared Character nth = '#'; + +} diff --git a/source/herd/schedule/chime/cron/package.ceylon b/source/herd/schedule/chime/cron/package.ceylon new file mode 100644 index 0000000..d07c673 --- /dev/null +++ b/source/herd/schedule/chime/cron/package.ceylon @@ -0,0 +1,3 @@ +"Parsing cron strings." +by( "Lis" ) +package herd.schedule.chime.cron; diff --git a/source/herd/schedule/chime/cron/parseCron.ceylon b/source/herd/schedule/chime/cron/parseCron.ceylon new file mode 100644 index 0000000..362f860 --- /dev/null +++ b/source/herd/schedule/chime/cron/parseCron.ceylon @@ -0,0 +1,67 @@ +import ceylon.time { + + today +} + + +"Parses cron expression from strings." +by( "Lis" ) +shared CronExpression? parseCron ( + "cron style string with seconds" String seconds, + "cron style string with minutes" String minutes, + "cron style string with hours" String hours, + "cron style string with daysOfMonth" String daysOfMonth, + "cron style string with months" String months, + "cron style string with days of week - optional if not specified all weekdays included" String? daysOfWeek = null, + "cron style string with years - optional if not specified every year included" String? years = null, + "maximum year period." Integer maxYearPeriod = 10 +) { + + // replace month names by numbers + variable String monthsToInt = calendar.replaceMonthByNumber( months.trimmed.uppercased ); + + // parse mandatory fields + value secondsSet = parseCronStyle( seconds, 0, 59 ); + value minutesSet = parseCronStyle( minutes, 0, 59 ); + value hoursSet = parseCronStyle( hours, 0, 23 ); + value daysOfMonthSet = parseCronStyle( daysOfMonth, 1, 31 ); + value monthsSet = parseCronStyle( monthsToInt, 1, 12 ); + + if ( !secondsSet.empty && !minutesSet.empty && !hoursSet.empty && !daysOfMonthSet.empty && !monthsSet.empty ) { + + // parse days of week, which is nonmandatory, if doesn't exists all days accepted + DaysOfWeekList daysOfWeekList; + if ( exists strDaysOfWeek = daysOfWeek ) { + // replace all weekday names by numbers + variable String weekdayToInt = calendar.replaceDayOfWeekByNumber( strDaysOfWeek.trimmed.uppercased ); + // do parsing + if ( exists parsedDaysOfWeek = parseCronDaysOfWeek( weekdayToInt ) ) { + daysOfWeekList = parsedDaysOfWeek; + } + else { + return null; + } + } + else { + daysOfWeekList = DaysOfWeekList( {DayOrderAll()} ); + } + + // parse years, which is nonmandatory, if doesn't exists any year accepted + Set yearsSet; + if ( exists strYears = years ) { + Integer todayYear = today().year; + yearsSet = parseCronStyle( strYears, todayYear, todayYear + maxYearPeriod ); + if ( yearsSet.empty ) { + return null; + } + } + else { + yearsSet = emptySet; + } + + return CronExpression( secondsSet, minutesSet, hoursSet, daysOfMonthSet, monthsSet, daysOfWeekList, yearsSet ); + } + else { + return null; + } +} diff --git a/source/herd/schedule/chime/cron/parseCronDaysOfWeek.ceylon b/source/herd/schedule/chime/cron/parseCronDaysOfWeek.ceylon new file mode 100644 index 0000000..a25d812 --- /dev/null +++ b/source/herd/schedule/chime/cron/parseCronDaysOfWeek.ceylon @@ -0,0 +1,77 @@ +import ceylon.collection { + + ArrayList, + HashSet, + linked, + Hashtable +} +import ceylon.time.base { + + dayOfWeek, + DayOfWeek +} + + +"Parses days of week from string." +by( "Lis" ) +DaysOfWeekList? parseCronDaysOfWeek( String expression ) { + + // all values + if ( expression == cron.allValues.string || expression.empty ) { + return DaysOfWeekList( {DayOrderAll()} ); + } + + ArrayList days = ArrayList(); + // parse tokens + {String*} tokens = expression.split( cron.delimiter.equals ).map( String.trimmed ); + for ( token in tokens ) { + if ( exists parsedToken = parseDayOrder( token ) ) { + days.add( parsedToken ); + } + else { + return null; + } + } + + if ( days.empty ) { + return null; + } + else { + return DaysOfWeekList( { for ( item in days ) item } ); + } + +} + + +"Parses day order from string." +by( "Lis" ) +DayOrder? parseDayOrder( String expression ) { + if ( expression.contains( cron.nth ) ) { + {String*} tokens = expression.split( cron.nth.equals ); + if ( tokens.size == 2, + exists weekday = parseStringToInteger( tokens.first ), + exists order = parseStringToInteger( tokens.last ) + ) { + if ( weekday > 0 && weekday < 8 && order > 0 && order < 6 ) { + return DayOrderNth( dayOfWeek( weekday - 1 ), order ); + } + } + } + else if ( exists last = expression.last, last == cron.last ) { + if ( exists day = parseStringToInteger( expression.spanTo( expression.size - 2 ) ) ) { + if ( day > 0 && day < 8 ) { + return DayOrderLast( dayOfWeek( day - 1 ) ); + } + } + } + else { + if ( exists daysSet = parseCronRange( expression, 1, 7 ) ) { + return DayOrderWeek ( + HashSet ( + linked, Hashtable(), daysSet.map( ( Integer element ) => dayOfWeek( element - 1 ) ) + ) + ); + } + } + return null; +} diff --git a/source/herd/schedule/chime/cron/parseCronRange.ceylon b/source/herd/schedule/chime/cron/parseCronRange.ceylon new file mode 100644 index 0000000..09d4c5e --- /dev/null +++ b/source/herd/schedule/chime/cron/parseCronRange.ceylon @@ -0,0 +1,115 @@ +import ceylon.collection { + + HashSet +} + + +"Parses cron range in format TO-FROM / STEP + where TO, FROM and STEP: + * contains only digits, + * `FROM`-`TO`/`STEP`, `FROM`, `TO` and `STEP` are digits + * `FROM`/`STEP`, `FROM` and `STEP` are digits, TO eqauls to max possible value + * `FROM`-`TO`, FROM and TO are digits, step is supposed to be 1 + * TO and FROM have to be greater or equal min and less or equal max +" +by( "Lis" ) +Set? parseCronRange( String expression, Integer minValue, Integer maxValue ) { + HashSet ret = HashSet(); + {String*} ranged = expression.split( cron.special.contains, false ).map( String.trimmed ); + if ( exists from = parseStringToInteger( ranged.first ) ) { + variable Integer to = from; + variable Integer step = 1; + if ( ranged.size == 1 ) { + if ( from < minValue || from > maxValue ) { + // 'from' to be within accepted values + return null; + } + } + else if ( ranged.size == 3 ) { + if ( exists del = ranged.getFromFirst( 1 ) ) { + if ( del == cron.range.string ) { + if ( exists parsed = parseStringToInteger( ranged.getFromFirst( 2 ) ) ) { + to = parsed; + if ( to < from || to > maxValue ) { + // 'to' to be within accepted values + return null; + } + } + else { + // not digits + return null; + } + } + else if ( del == cron.increments.string ) { + if ( exists parsed = parseStringToInteger( ranged.getFromFirst( 2 ) ) ) { + if ( parsed < 1 ) { + // step to be greater zero + return null; + } + step = parsed; + to = maxValue; + } + else { + // not digits + return null; + } + } + else { + // only '-' or '/' supported + return null; + } + } + else { + return null; + } + } else if ( ranged.size == 5 ) { + if ( exists del1 = ranged.getFromFirst( 1 ), exists del2 = ranged.getFromFirst( 3 ) ) { + if ( del1 == cron.range.string && del2 == cron.increments.string ) { + if ( exists parsedTo = parseStringToInteger( ranged.getFromFirst( 2 ) ), + exists parsedStep = parseStringToInteger( ranged.getFromFirst( 4 ) ) ) + { + if ( parsedTo < minValue || parsedTo > maxValue || parsedStep < 1 ) { + // incorrect values + return null; + } + to = parsedTo; + step = parsedStep; + } + else { + // not digits + return null; + } + } + else { + // incorrect format - to be X-X/X + return null; + } + } + else { + return null; + } + } + else { + // token must be in format X-X/X + return null; + } + if ( step < 1 ) { + step = 1; + } + // store range into set + variable Integer storing = from; + while ( storing <= to && storing <= maxValue ) { + if ( storing >= minValue ) { + ret.add( storing ); + } + storing += step; + } + if ( ret.empty ) { + return null; + } + else { + return ret; + } + } + return null; +} diff --git a/source/herd/schedule/chime/cron/parseCronStyle.ceylon b/source/herd/schedule/chime/cron/parseCronStyle.ceylon new file mode 100644 index 0000000..11c4b85 --- /dev/null +++ b/source/herd/schedule/chime/cron/parseCronStyle.ceylon @@ -0,0 +1,46 @@ +import ceylon.collection { + + HashSet +} + + +"Parses string expression with cron style time, returns set with all possible values. + Supported time in format X,X,... + where X to be: + * digits greater or equal min and less or equal max, + * `FROM`-`TO`/`STEP`, `FROM`, `TO` and `STEP` are digits + * `FROM`/`STEP`, `FROM` and `STEP` are digits, TO eqauls to max possible value + * `FROM`-`TO`, FROM and TO are digits, step is supposed to be 1 + " +by( "Lis" ) +Set parseCronStyle( + "expression to be parsed" String expression, + "min possible value" Integer minValue, + "max possible value" Integer maxValue ) +{ + String trimmedExpr = expression.trimmed; + HashSet ret = HashSet(); + + // all values + if ( trimmedExpr == cron.allValues.string ) { + variable Integer storing = minValue; + while ( storing <= maxValue ) { + ret.add( storing ); + storing ++; + } + return ret; + } + + // parse tokens + {String*} tokens = trimmedExpr.split( cron.delimiter.equals ).map( String.trimmed ); + for ( token in tokens ) { + if ( exists tokenSet = parseCronRange( token, minValue, maxValue ) ) { + ret.addAll( tokenSet ); + } + else { + ret.clear(); + break; + } + } + return ret; +} diff --git a/source/herd/schedule/chime/cron/stringToInteger.ceylon b/source/herd/schedule/chime/cron/stringToInteger.ceylon new file mode 100644 index 0000000..100535c --- /dev/null +++ b/source/herd/schedule/chime/cron/stringToInteger.ceylon @@ -0,0 +1,10 @@ + +"Parses Integer from String?." +Integer? parseStringToInteger( String? str ) { + if ( exists parsing = str ) { + return parseInteger( parsing ); + } + else { + return null; + } +} diff --git a/source/herd/schedule/chime/errorMessages.ceylon b/source/herd/schedule/chime/errorMessages.ceylon new file mode 100644 index 0000000..7d8c9a8 --- /dev/null +++ b/source/herd/schedule/chime/errorMessages.ceylon @@ -0,0 +1,48 @@ + +"Defines error messages." +by( "Lis" ) +shared object errorMessages { + + shared String unsupportedOperation = "unsupported operation"; + + shared String operationIsNotSpecified = "operation has to be specified"; + + + shared String schedulerNotExists = "scheduler doesn't exist"; + + shared String schedulerNameHasToBeSpecified = "scheduler name has to be specified"; + + shared String incorrectSchedulerState = "scheduler state has to be one of - 'get', 'paused', 'running'"; + + shared String stateToBeSpecified = "state has to be specified"; + + + shared String timerAlreadyExists = "timer already exists"; + + shared String timerNotExists = "timer doesn't exist"; + + shared String timerNameHasToBeSpecified = "timer name has to be specified"; + + shared String timerTypeHasToBeSpecified = "timer type has to be specified"; + + shared String unsupportedTimerType = "unsupported timer type"; + + shared String incorrectStartDate = "incorrect start date"; + + shared String incorrectEndDate = "incorrect end date"; + + shared String endDateToBeAfterStartDate = "end date has to be after start date"; + + shared String unsupportedTimezone = "unsupported time zone"; + + shared String timerDescriptionHasToBeSpecified = "timer description has to be specified"; + + shared String incorrectTimerState = "timer state has to be one of - 'get', 'paused', 'running'"; + + shared String intervalHasToBeSpecified = "interval has to be specified"; + + shared String intervalHasToBeGreaterThanZero = "interval has to be greater than zero"; + + shared String incorrectCronTimerDescription = "incorrect cron timer description"; + +} diff --git a/source/herd/schedule/chime/module.ceylon b/source/herd/schedule/chime/module.ceylon new file mode 100644 index 0000000..1109f61 --- /dev/null +++ b/source/herd/schedule/chime/module.ceylon @@ -0,0 +1,401 @@ + +" + _Chime_ is time scheduler which works on _Vert.x_ event bus and provides: + * scheduling with _cron-style_ and _interval_ timers + * applying time zones available on _JVM_ + + >Compiled for Ceylon 1.2.0 and Vert.x 3.2.1 + + + ## Running. + + Deploy _Chime_ using `Vertx.deployVerticle` method. + + import io.vertx.ceylon.core { vertx } + + vertx.vertx().deployVerticle ( + \"ceylon:herd.schedule.chime/0.1.0\", + (String|Throwable res) { + ... + } + ); + + + >_Chime_ exchanges events with customers via event bus with `JSON` messages. + + + ## Configuration. + + Following parameters could be specified in `JSON` verticle configuration: + + * \"address\" -> address _Chime_ is listen to, `String`, default is \"chime\" + * \"max year period limit\" -> limiting scheduling period in years, `Integer`, default is 10 years + * \"tolerance\" -> tolerance in milliseconds used to compare actual and requested times, + `Integer`, default is 10 milliseconds + + + ## Scheduling. + + _Chime_ operates by two structures: _timer_ and _scheduler_. + _Scheduler_ is a set or group of timers. At least one _scheduler_ has to be created before creating _timers_. + + + ### _Scheduler_. + + ##### Scheduler messages. + + In order to maintain _schedulers_ send `JSON` message on _Chime_ address (specified in configuration, \"chime\" is default) + in the following format: + { + \"operation\" -> String // operation code, mandatory + \"name\" -> String // scheduler name, mandatory + \"state\" -> String // state, mandatory only if operation = 'state' + } + + >_Chime_ listens event bus on \"scheduler name\" address with messages for the given _scheduler_. + + + ##### Scheduler operation codes. + + * \"create\" - create new scheduler with specified name, state and description, + if state is not specified, scheduler is put to running state. + * \"delete\" - delete scheduler with name `name`. All timers within _scheduler_ will be canceled. + * \"info\" - requesting info on _Chime_ or specific scheduler (scheduler name to be provided) + * \"state\": + * if is \"get\" state is to be returned + * if is \"running\" scheduler is to be set to _running_, which leads all non paused timers are _running_ + * if is \"paused\" scheduler is to be set to _paused_, which leads all timers are _paused_ + * otherwise error is returned + + + ##### Scheduler examples. + + // create new scheduler with name \"scheduler name\" + JSON message = JSON { + \"operation\" -> \"create\", + \"name\" -> \"scheduler name\" + } + + // change state of scheduler with \"scheduler name\" on paused + JSON message = JSON { + \"operation\" -> \"state\", + \"name\" -> \"scheduler name\", + \"state\" -> \"paused\" + } + + + ##### Scheduler response. + + _Chime_ responds on messages in `JSON` format: + { + \"response\" -> String // response code - one of \"ok\" or \"error\" + \"name\" -> String // scheduler name + \"state\" -> String // scheduler state + \"schedulers\" -> JSONArray // scheduler names, exists as response on \"info\" operation with no \"name\" field + \"error\" -> String // error description, exists only if response == \"error\" + } + + + ### _Timer_. + + Once _shceduler_ is created _timers_ can be run within. + + There are two ways to access specific timer: + * sending message on \"scheduler name\" address using timer short name \"timer name\" + * sending message on _Chime_ address using full timer name which is \"scheduler name:timer name\" + + + >Timer full name is _scheduler name_ and _timer name_ separated with ':', i.e. \"scheduler name:timer name\". + + + >Request on _Chime_ address with _timer full name_ and request on _scheduler_ address with timer full or short name + are equivalent. + + + ##### Timer request. + + Request has to be sent in `JSON` format on _scheduler name_ address with _timer short name_ + or on _Chime_ address with _timer full name_. + Request format: + + { + \"operation\" -> String // operation code, mandatory + \"name\" -> String // timer short or full name, mandatory + \"state\" -> String // state, nonmandatory, except if operation = 'sate' + + // fields for create operation: + \"maximum count\" -> Integer // maximum number of fires, default - unlimited + \"publish\" -> Boolean // if true message to be published and to be sent otherwise, nonmandatory + + \"start time\" -> JSON // start time, nonmadatory, if doesn't exist timer will start immediately + { + \"seconds\" -> Integer // seconds, mandatory + \"minutes\" -> Integer // minutes, mandatory + \"hours\" -> Integer // hours, mandatory + \"day of month\" -> Integer // days of month, mandatory + \"month\" -> Integer or String // months, mandatory + \"year\" -> Integer // year, mandatory + } + + \"end time\" -> `JSON` // end time, nonmadatory, default no end time + { + \"seconds\" -> Integer // seconds, mandatory + \"minutes\" -> Integer // minutes, mandatory + \"hours\" -> Integer // hours, mandatory + \"day of month\" -> Integer // days of month, mandatory + \"month\" -> Integer or String // months, mandatory + \"year\" -> Integer // year, mandatory + } + + \"time zone\" -> String // time zone ID, nonmandatory, default server local + + \"description\" -> JSON // timer desciption, mandatoty for create operation + } + + + >_Chime_ address could be specified in `verticle` configuration, default is \"chime\". + + + ##### Timer operation codes. + + * \"create\" - create new timer with specified name, state and description + * \"delete\" - delete timer with name `name` + * \"info\" - get information on timer (if timer name is specified) or scheduler (if timer name is not specified) + * \"state\": + * if state field is \"get\" timer state is to be returned + * if state field is \"running\" timer state is to be set to _running_ + * if state field is \"paused\" timer state is to be set to _paused_ + * otherwise error is returned + + >Timer fires only if both _timer_ and _scheduler_ states are _running_. + + + ##### Supported timers. + + Timer is specified within _description_ field of timer creation request. + + * __Cron style timer__. Timer which is defined like _cron_, but with some simplifications + { + \"type\" -> \"cron\" // timer type, mandatory + + \"seconds\" -> String // seconds in cron style, mandatory + \"minutes\" -> String // minutes in cron style, mandatory + \"hours\" -> String // hours in cron style, mandatory + \"days of month\" -> String // days of month in cron style, mandatory + \"months\" -> String // months in cron style, mandatory + \"days of week\" -> String // days of week in cron style, L means last, # means nth of month, nonmandatory + \"years\" -> String // year in cron style, nonmandatory + } + All fields can be specified using following notations: + * `FROM`-`TO`/`STEP` + * `FROM`/`STEP` + * `FROM`-`TO` + * '*' means any allowed + * month can be specified using digits (1 is for January) or using names (like 'jan' or 'january', case insensitive) + * day of week can be specified using digits (1 is for Sunday) or using names (like 'sun' or 'sunday', case insensitive) + + ------------------------------------------ + + * __Interval timer__. Timer which fires after each given time period (minimum 1 second) + { + \"type\" -> \"interval\" // timer type, mandatory + \"increment seconds\" -> Integer // increment in seconds, if <= 0 timer fires only once, mandatory + } + + + ##### Scheduler response on timer request. + + _Chime_ responds on each request in `JSON` format: + { + \"response\" -> String // response code - one of `ok` or `error` + \"name\" -> String // timer name + \"state\" -> String // state + + \"error\" -> String // error description, exists only if response == `error` + \"timers\" -> JSONArray // list of timer names currently scheduled - response on info operation with no name field specified + + // 'Info' operation returns fields from 'create' operation + } + + + ##### Timer firing. + + When _timer_ fires it sends or publishes `JSON` message on _full timer name_ address in the following format: + + { + \"name\" -> String, timer name + \"count\" -> Integer, total number of fire times + \"state\" -> String, timer state, one of 'running', 'paused' or 'completed' + \"time\" -> String formated time / date + \"seconds\" -> Integer, number of seconds since last minute + \"minutes\" -> Integer, number of minutes since last hour + \"hours\" -> Integer, hour of day + \"day of month\" -> Integer, day of month + \"month\" -> Integer, month + \"year\" -> Integer, year + \"time zone\" -> String, time zone ID + } + + + >_Timer full name_ is _scheduler name_ and _timer name_ separated with ':', i.e. \"scheduler name:timer name\". + + + >Timer _sends_ or _publishes_ message depending on \"publish\" field in timer description (passed at timer creation request). + + + >String formatted time / date is per [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601). + + + ##### Time zones. + + [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), + actual availability may depend on particular JVM installation. + + see also [time zones and JRE](http://www.oracle.com/technetwork/java/javase/dst-faq-138158.html). + + + ##### Timer example. + + eventBus.send ( + \"chime\", + JSON { + \"operation\" -> \"create\", + \"name\" -> \"schedule manager\", + \"state\" -> \"running\" + }, + (Throwable|Message msg) { + if (is Message msg) { + eventBus.send( + \"chime\", + JSON { + \"operation\" -> \"create\", + \"name\" -> \"schedule manager:schedule timer\", + \"state\" -> \"running\", + \"publish\" -> false, + \"max count\" -> 3, + \"time zone\" -> \"Europe/Paris\", + \"descirption\" -> JSON { + \"type\" -> \"cron\", // timer type is 'cron' + \"seconds\" -> \"27/30\", // 27 with step 30 leads to fire at 27 and 57 seconds + \"minutes\" -> \"*\", // every minute + \"hours\" -> \"0-23\", // every hour + \"days of month\" -> \"1-31\", // every day + \"months\" -> \"january-OCTOBER\", // from January and up to October + \"days of week\" -> \"sat#2,sunday\", // at second Saturday and at each Sunday + \"years\" -> \"2015-2019\" + } + }, + (Throwable|Message msg) { + print(msg); + } + ); + } + else { + print(\"time scheduler creation error: \`\`msg\`\`\"); + } + } + ); + + + ### Error messages. + + When error occured _Chime_ replies on corresponding message with error: + { + \"response\" -> \"error\" + \"error\" -> String with error description + } + + possible errors (see [[value errorMessages]]): + * \"unsupported operation\" + * \"operation has to be specified\" + * \"scheduler doesn't exist\" + * \"scheduler name has to be specified\" + * \"scheduler state has to be one of - 'get', 'paused', 'running'\" + * \"state has to be specified\" + * \"timer already exists\" + * \"timer doesn't exist\" + * \"timer name has to be specified\" + * \"timer type has to be specified\" + * \"unsupported timer type\" + * \"incorrect start date\" + * \"incorrect end date\" + * \"end date has to be after start date\" + * \"unsupported time zone\" + * \"timer description has to be specified\" + * \"timer state has to be one of - 'get', 'paused', 'running'\" + * \"interval has to be specified\" + * \"interval has to be greater than zero\" + * \"incorrect cron timer description\" + + + ## Cron expressions. + + + ##### Expression fields. + + * _seconds_, mandatory + * allowed values: 0-59 + * allowed special characters: , - * / + * _minutes_, mandatory + * allowed values: 0-59 + * allowed special characters: , - * / + * _hours_, mandatory + * allowed values: 0-23 + * allowed special characters: , - * / + * _days of month_, mandatory + * allowed values 1-31 + * allowed special characters: , - * / + * _months_, mandatory + * allowed values 1-12, Jan-Dec, January-December + * allowed special characters: , - * / + * _days of week_, nonmandatory + * allowed values 1-7, Sun-Sat, Sunday-Saturday + * allowed special characters: , - * / L # + * _years_, nonmandatory + * allowed values 1970-2099 + * allowed special characters: , - * / + + + >Names of months and days of the week are _not_ case sensitive. + + + ##### Special characters. + + * '*' means all values + * ',' separates list items + * '-' specifies range, for example, '10-12' means '10, 11, 12' + * '/' specifies increments, for example, '0/15' in seconds field means '0,15,30,45', + '0-30/15' means '0,15,30' + * 'L' has to be used after digit and means _the last xxx day of the month_, + for example, '6L' means _the last friday of the month_ + * '#' has to be used with digits before and after: 'x#y' and means _the y'th x day of the month_, + for example, '6#3' means _the third Friday of the month_ + + " +license ( + "The MIT License (MIT) + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the \"Software\"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE." +) +by( "Lis" ) +native( "jvm" ) +module herd.schedule.chime "0.1.0" { + shared import io.vertx.ceylon.core "3.2.1"; + import ceylon.time "1.2.0"; +} diff --git a/source/herd/schedule/chime/package.ceylon b/source/herd/schedule/chime/package.ceylon new file mode 100644 index 0000000..04a25dd --- /dev/null +++ b/source/herd/schedule/chime/package.ceylon @@ -0,0 +1 @@ +shared package herd.schedule.chime; diff --git a/source/herd/schedule/chime/timer/StandardTimerFactory.ceylon b/source/herd/schedule/chime/timer/StandardTimerFactory.ceylon new file mode 100644 index 0000000..799f0c6 --- /dev/null +++ b/source/herd/schedule/chime/timer/StandardTimerFactory.ceylon @@ -0,0 +1,89 @@ +import ceylon.json { + + JSON=Object +} +import herd.schedule.chime.cron { + + calendar, + parseCron +} +import herd.schedule.chime { + + errorMessages +} + + +"Standard time factory. Creates: + * cron-like timer [[herd.schedule.chime.timer::TimerCronStyle]] + * incremental timer [[herd.schedule.chime.timer::TimerInterval]] + " +shared class StandardTimerFactory( "max year limitation" Integer maxYearPeriod = 10 ) extends FactoryJSONBase() + satisfies TimerFactory + { + + + "Initializes factory - to be called before using (creators adding is performed here)." + shared TimerFactory initialize() { + addCreator( timerDefinitions.typeCronStyle, createCronTimer ); + addCreator( timerDefinitions.typeInterval, createIntervalTimer ); + return this; + } + + + // timer creators + + "Creates cron style timer." + Timer|String createCronTimer( "Timer description." JSON description ) { + if ( is String seconds = description.get( calendar.seconds ), + is String minutes = description.get( calendar.minutes ), + is String hours = description.get( calendar.hours ), + is String daysOfMonth = description.get( calendar.daysOfMonth ), + is String months = description.get( calendar.months ) + ) { + // days of week - nonmandatory + String? daysOfWeek; + if ( is String str = description.get( calendar.daysOfWeek ) ) { + daysOfWeek = str; + } + else { + daysOfWeek = null; + } + + // years - nonmandatory + String? years; + if ( is String str = description.get( calendar.years ) ) { + years = str; + } + else { + years = null; + } + + if ( exists cronExpr = parseCron( seconds, minutes, hours, daysOfMonth, months, daysOfWeek, years, maxYearPeriod ) ) { + return TimerCronStyle( cronExpr ); + } + else { + return errorMessages.incorrectCronTimerDescription; + } + + } + else { + return errorMessages.incorrectCronTimerDescription; + } + } + + + "Creates interval timer." + Timer|String createIntervalTimer( "Timer description." JSON description ) { + if ( is Integer interval = description.get( timerDefinitions.intervalSeconds ) ) { + + if ( interval > 0 ) { + return TimerInterval( interval * 1000 ); + } + else { + return errorMessages.intervalHasToBeGreaterThanZero; + } + } + return errorMessages.intervalHasToBeSpecified; + } + +} diff --git a/source/herd/schedule/chime/timer/Timer.ceylon b/source/herd/schedule/chime/timer/Timer.ceylon new file mode 100644 index 0000000..48f4433 --- /dev/null +++ b/source/herd/schedule/chime/timer/Timer.ceylon @@ -0,0 +1,20 @@ +import ceylon.time { + + DateTime +} + + +"Timer interface." +by( "Lis" ) +shared interface Timer +{ + + "Starts the timer using [[current]] time. + Returns next fire time if successfull or null if completed." + shared formal DateTime? start( "current time" DateTime current ); + + "Shifts time to next one. + Returns next fire time if successfull and null if completed." + shared formal DateTime? shiftTime(); + +} diff --git a/source/herd/schedule/chime/timer/TimerCronStyle.ceylon b/source/herd/schedule/chime/timer/TimerCronStyle.ceylon new file mode 100644 index 0000000..7d2af37 --- /dev/null +++ b/source/herd/schedule/chime/timer/TimerCronStyle.ceylon @@ -0,0 +1,443 @@ +import ceylon.time { + + DateTime, + dateTime +} + +import ceylon.time.chronology { + + gregorian +} +import herd.schedule.chime.cron { + + CronExpression +} + + +"Ccron-like timer." +by( "Lis" ) +class TimerCronStyle ( + "Cron expression rules the timer." CronExpression expression +) + satisfies Timer +{ + + // indexies of current time + variable Iterator secondIndex = expression.seconds.iterator(); + variable Iterator minuteIndex = expression.minutes.iterator(); + variable Iterator hourIndex = expression.hours.iterator(); + variable Iterator dayIndex = expression.daysOfMonth.iterator(); + variable Iterator monthIndex = expression.months.iterator(); + variable Iterator yearIndex = expression.years.iterator(); + + // current time + variable Integer currentYear = 0; + variable Integer currentMonth = 1; + variable Integer currentDay = 1; + variable Integer currentHour = 0; + variable Integer currentMinute = 0; + variable Integer currentSecond = 0; + + "current date and time" + variable DateTime currentDate = dateTime( 0, 1, 1 ); + + variable Boolean completed = false; + + + "Completes the timer." + void completeTimer() { + completed = true; + currentDate = dateTime( 0, 1, 1 ); + secondIndex = expression.seconds.iterator(); + minuteIndex = expression.minutes.iterator(); + hourIndex = expression.hours.iterator(); + dayIndex = expression.daysOfMonth.iterator(); + monthIndex = expression.months.iterator(); + yearIndex = expression.years.iterator(); + } + + "Starts seconds from beginning." + void resetSeconds() { + secondIndex = expression.seconds.iterator(); + if ( is Integer val = secondIndex.next() ){ + currentSecond = val; + } + else { + currentSecond = 0; + } + } + + "Starts minutes from beginning and reset seconds." + void resetMinutes() { + minuteIndex = expression.minutes.iterator(); + if ( is Integer val = minuteIndex.next() ){ + currentMinute = val; + } + else { + currentMinute = 0; + } + resetSeconds(); + } + + "Starts hours from beginning and reset minutes." + void resetHours() { + hourIndex = expression.hours.iterator(); + if ( is Integer val = hourIndex.next() ){ + currentHour = val; + } + else { + currentHour = 0; + } + resetMinutes(); + } + + "Start. days from beginning and reset hours." + void resetDays() { + dayIndex = expression.daysOfMonth.iterator(); + if ( is Integer val = dayIndex.next() ){ + currentDay = val; + } + else { + currentDay = 1; + } + resetHours(); + } + + "Starts days from beginning and reset and reset days." + void resetMonth() { + monthIndex = expression.months.iterator(); + if ( is Integer val = monthIndex.next() ){ + currentMonth = val; + } + else { + currentMonth = 1; + } + resetDays(); + } + + "Returns `true` if data is accepted and `false` otherwise." + Boolean isDateAcepted() { + value converted = gregorian.dateFrom( gregorian.fixedFrom( [currentYear, currentMonth, currentDay] ) ); + return converted[0] == currentYear && converted[1] == currentMonth && converted[2] == currentDay; + } + + "Shifts year to next after the latest fire. Next year is one from specified in [[CronExpression.years]]. + If all years scooped out timing is completed. + Returns `true` if completed." + Boolean shiftYear() { + // shift to next year + if ( !expression.years.empty ) { + if ( is Integer item = yearIndex.next() ) { + currentYear = item; + } + else { + return true; + } + } + else { + currentYear ++; + } + return false; + } + + "Shifts month to next after the latest fire. Next month is one from specified in [[CronExpression.months]]. + If all months scooped out shifts year - [[shiftYear]]. + Returns `true` if completed and `false` otherwise." + Boolean shiftMonth() { + // shift to next month + if ( is Integer item = monthIndex.next() ) { + currentMonth = item; + if ( isDateAcepted() ) { + return false; + } + else { + resetMonth(); + return shiftYear(); + } + } + else { + resetMonth(); + return shiftYear(); + } + } + + "Shifts day to next after the latest fire. Next day is one from specified in [[CronExpression.daysOfMonth]]. + If all days scooped out shifts month - [[shiftMonth]]. + Returns `true` if completed and `false` otherwise." + Boolean shiftDay() { + // shift to next day + if ( is Integer item = dayIndex.next() ) { + currentDay = item; + if ( isDateAcepted() ) { + return false; + } + else { + resetDays(); + return shiftMonth(); + } + } + else { + resetDays(); + return shiftMonth(); + } + } + + "Shifts hours to next after the latest fire. Next hours are one from specified in [[CronExpression.hours]]. + If all hours scooped out shifts day - [[shiftDay]]. + Returns `true` if completed and `false` otherwise." + Boolean shiftHour() { + // shift to next hour + if ( is Integer item = hourIndex.next() ) { + currentHour = item; + return false; + } + else { + resetHours(); + return shiftDay(); + } + } + + "Shifts minutes to next after the latest fire. Next minutes are one from specified in [[CronExpression.minutes]]. + If all minutes scooped out shifts hours - [[shiftHour]]. + Returns `true` if completed and `false` otherwise." + Boolean shiftMinute() { + // shift to next minute + if ( is Integer item = minuteIndex.next() ) { + currentMinute = item; + return false; + } + else { + resetMinutes(); + return shiftHour(); + } + } + + "Shifts seconds to next after the latest fire. Next seconds are one from specified in [[CronExpression.seconds]]. + If all seconds scooped out shifts minutes - [[shiftMinute]]. + Returns `true` if completed and `false` otherwise." + Boolean shiftSecond() { + if ( is Integer item = secondIndex.next() ) { + currentSecond = item; + return false; + } + else { + resetSeconds(); + return shiftMinute(); + } + + } + + "Considers weekdays in the [[currentDate]]. I.e. shifts days while [[currentDay]] is not within [[CronExpression.daysOfWeek]]. + Returns `false` if timer to be completed and `true` otherwise." + Boolean considerWeekdays() { + try { + variable DateTime date = dateTime( currentYear, currentMonth, currentDay, currentHour, currentMinute, currentSecond ); + // shift to appropriate day of week + variable Boolean reset = true; + while ( !expression.daysOfWeek.falls( date.date ) && date != currentDate ) { + if ( reset ) { + resetHours(); + reset = false; + } + if ( shiftDay() ) { + completeTimer(); + return false; + } + date = dateTime( currentYear, currentMonth, currentDay, currentHour, currentMinute, currentSecond ); + } + if ( date != currentDate ) { + currentDate = date; + return true; + } + else { + completeTimer(); + return false; + } + } + catch ( Throwable err ) { + completeTimer(); + return false; + } + } + + + "Starts timing from specified UTC time. + Returns `true` if started and `false` if completed." + Boolean startCron( DateTime current ) { + // find nearest time + + // year + if ( expression.years.empty ) { + currentYear = current.year; + } + else { + currentYear = 0; + yearIndex = expression.years.iterator(); + while ( is Integer item = yearIndex.next() ) { + if ( item >= current.year ) { + currentYear = item; + break; + } + } + if ( currentYear == 0 ) { + completeTimer(); + return false; + } + else if ( currentYear > current.year ) { + resetMonth(); + return considerWeekdays(); + } + } + + // month + currentMonth = 0; + monthIndex = expression.months.iterator(); + while ( is Integer item = monthIndex.next() ) { + if ( item >= current.month.integer ) { + currentMonth = item; + break; + } + } + if ( currentMonth == 0 ) { + resetMonth(); + if ( shiftYear() ) { + completeTimer(); + return false; + } + else { + return considerWeekdays(); + } + } + if ( currentMonth > current.month.integer ) { + resetDays(); + return considerWeekdays(); + } + + // day + currentDay = 0; + dayIndex = expression.daysOfMonth.iterator(); + while ( is Integer item = dayIndex.next() ) { + if ( item >= current.day ) { + currentDay = item; + break; + } + } + if ( currentDay == 0 ) { + resetDays(); + if ( shiftMonth() ) { + completeTimer(); + return false; + } + else { + return considerWeekdays(); + } + } + if ( currentDay > current.day ) { + resetHours(); + return considerWeekdays(); + } + + // hour + currentHour = -1; + hourIndex = expression.hours.iterator(); + while ( is Integer item = hourIndex.next() ) { + if ( item >= current.hours ) { + currentHour = item; + break; + } + } + if ( currentHour == -1 ) { + resetHours(); + if ( shiftDay() ) { + completeTimer(); + return false; + } + else { + return considerWeekdays(); + } + } + if ( currentHour > current.hours ) { + resetMinutes(); + return considerWeekdays(); + } + + // minutes + currentMinute = -1; + minuteIndex = expression.minutes.iterator(); + while ( is Integer item = minuteIndex.next() ) { + if ( item >= current.minutes ) { + currentMinute = item; + break; + } + } + if ( currentMinute == -1 ) { + resetMinutes(); + if ( shiftHour() ) { + completeTimer(); + return false; + } + else { + return considerWeekdays(); + } + } + if ( currentMinute > current.minutes ) { + resetSeconds(); + return considerWeekdays(); + } + + // seconds + currentSecond = -1; + secondIndex = expression.seconds.iterator(); + while ( is Integer item = secondIndex.next() ) { + if ( item >= current.seconds ) { + currentSecond = item; + break; + } + } + if ( currentSecond == -1 ) { + resetSeconds(); + if ( shiftMinute() ) { + completeTimer(); + return false; + } + } + + // shift to appropriate day of week + return considerWeekdays(); + + } + + "Calculates next local time and stores it in [[currentDate]]. + Returns `true` if successfully shifted and `false` if to be completed." + Boolean shiftCronTime() { + if ( shiftSecond() ) { + completeTimer(); + return false; + } + else { + return considerWeekdays(); + } + } + + + /* Timer interface */ + + shared actual DateTime? start( DateTime current ) { + if ( startCron( current ) ) { + return currentDate; + } + else { + return null; + } + } + + shared actual DateTime? shiftTime() { + if ( shiftCronTime() ) { + return currentDate; + } + else { + return null; + } + } + +} diff --git a/source/herd/schedule/chime/timer/TimerFactory.ceylon b/source/herd/schedule/chime/timer/TimerFactory.ceylon new file mode 100644 index 0000000..d5f0259 --- /dev/null +++ b/source/herd/schedule/chime/timer/TimerFactory.ceylon @@ -0,0 +1,55 @@ +import ceylon.json { + + JSON=Object +} +import ceylon.collection { + + HashMap +} +import herd.schedule.chime { + + errorMessages +} + + +"Factory to create timers." +by( "Lis" ) +shared interface TimerFactory +{ + shared formal Timer|String createTimer( "Timer description." JSON description ); +} + + +"Base timer factory. + Uses type -> creator function map to create timers. + Before create timers add creators using [[addCreator]]" +by( "Lis" ) +shared class FactoryJSONBase() satisfies TimerFactory +{ + + "type -> creator function map" + HashMap(JSON)> creators = HashMap(JSON)>(); + + + "Adds creator to the factory." + shared void addCreator( "Timer type." String type, "Creator function." (JSON) creator ) { + creators.put( type, creator ); + } + + "Searches creators from added via [[addCreator]] and use them to create timers. + description to contain field \"type\" which is used to find creator function." + shared actual Timer|String createTimer( "timer description" JSON description ) { + if ( is String type = description.get( definitions.type ) ) { + if ( exists creator = creators.get( type ) ) { + return creator( description ); + } + else { + return errorMessages.unsupportedTimerType; + } + } + else { + return errorMessages.timerTypeHasToBeSpecified; + } + } + +} diff --git a/source/herd/schedule/chime/timer/TimerInterval.ceylon b/source/herd/schedule/chime/timer/TimerInterval.ceylon new file mode 100644 index 0000000..1f6c98b --- /dev/null +++ b/source/herd/schedule/chime/timer/TimerInterval.ceylon @@ -0,0 +1,35 @@ +import ceylon.time { + + DateTime, + dateTime +} + + +"Incremental timer - starts from specific time date + and increments on certain miliseconds each time when fired - [[intervalMilliseconds]]. + [[intervalMilliseconds]] to be >= 0. + " +by( "Lis" ) +class TimerInterval ( + "Timing interval in miliseconds, to be >= 0." shared Integer intervalMilliseconds +) + satisfies Timer +{ + "Current date and time." + variable DateTime currentDate = dateTime( 0, 1, 1 ); + + + /* Timer interface */ + + shared actual DateTime? start( DateTime current ) { + // next fire time + currentDate = current.plusMilliseconds( intervalMilliseconds ); + return currentDate; + } + + shared actual DateTime? shiftTime() { + currentDate = currentDate.plusMilliseconds( intervalMilliseconds ); + return currentDate; + } + +} diff --git a/source/herd/schedule/chime/timer/definitions.ceylon b/source/herd/schedule/chime/timer/definitions.ceylon new file mode 100644 index 0000000..acd5622 --- /dev/null +++ b/source/herd/schedule/chime/timer/definitions.ceylon @@ -0,0 +1,104 @@ + + +"Defines scheduler constants." +by( "Lis" ) +shared object definitions +{ + + // configuration + + "default listening address" + shared String defaultAddress = "chime"; + + + // configuration names + + "listening address" + shared String address = "address"; + + "max year period limit" + shared String maxYearPeriodLimit = "max year period limit"; + + "tolerance to compare fire time and current time in miliseconds" + shared String tolerance = "tolerance"; + + + "separator of manager and timer name" + shared String nameSeparator = ":"; + + + + // time scheduler command / return fields + + "operation - to contain operation code" + shared String fieldOperation = "operation"; + + "timer name" + shared String fieldName = "name"; + + "timer state" + shared String fieldState = "state"; + + "response code" + shared String fieldResponse = "response"; + + "error field - contains error description, fieldResponse to be `error`" + shared String fieldError = "error"; + + "timer description" + shared String fieldDescription = "descirption"; + + "time field" + shared String fieldTime = "time"; + + "time count" + shared String fieldCount = "count"; + + "field with schedulers array" + shared String fieldSchedulers = "schedulers"; + + "field with timers array" + shared String fieldTimers = "timers"; + + + "field max count" + shared String fieldMaxCount = "max count"; + + "publish field" + shared String fieldPublish = "publish"; + + "start time field" + shared String fieldStartTime = "start time"; + "end time field" + shared String fieldEndTime = "end time"; + + + // time zone + shared String timeZoneID = "time zone"; + + + // operation codes + + "create timer" + shared String opCreate = "create"; + "delete timer" + shared String opDelete = "delete"; + "get or modify shceduler or timer state (pause, run)" + shared String opState = "state"; + "get total or scheduler info" + shared String opInfo = "info"; + + "get state" + shared String stateGet = "get"; + + "timer description type field" + shared String type = "type"; + + // response codes + + "operation accepted" + shared String responseOK = "ok"; + "error has been occured during operation execution - see `error` field" + shared String responseError = "error"; + +} diff --git a/source/herd/schedule/chime/timer/package.ceylon b/source/herd/schedule/chime/timer/package.ceylon new file mode 100644 index 0000000..2407ab8 --- /dev/null +++ b/source/herd/schedule/chime/timer/package.ceylon @@ -0,0 +1,7 @@ +"Contains different timers and factory to create timers. + Supported timers: + * cron-style timer [[TimerCronStyle]] + * interval timer [[TimerInterval]] + " +by( "Lis" ) +package herd.schedule.chime.timer; diff --git a/source/herd/schedule/chime/timer/timerDefinitions.ceylon b/source/herd/schedule/chime/timer/timerDefinitions.ceylon new file mode 100644 index 0000000..fdb541b --- /dev/null +++ b/source/herd/schedule/chime/timer/timerDefinitions.ceylon @@ -0,0 +1,15 @@ + + +"defines timer constants" +by( "Lis" ) +object timerDefinitions +{ + + // increment in seconds + shared String intervalSeconds = "interval seconds"; + + // timer types + shared String typeCronStyle = "cron"; + shared String typeInterval = "interval"; + +}