Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add spring workers manager #57

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,10 @@ Disables Spring OAuth2 resource server for testing.
## s3-storage

Amazon S3 support.

# spring-workers-agent

Framework for clustered scheduling based on Quartz and Spring Data.
Stores all data in a database (Postgres by default).

Used as manageable alternative for Spring's `@Scheduled`.
3 changes: 3 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@ include("security-resource-server-custom-jwt-configuration")
include("security-resource-server-test-jwt-configuration")
include("security-jwt-common")
include("s3-storage")
include("spring-workers-manager-core")
include("spring-workers-manager-agent")
include("spring-workers-manager-api")
77 changes: 77 additions & 0 deletions spring-workers-manager-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Workers Agent

Framework for clustered scheduling based on Quartz and Spring Data.
Stores all data in a database (Postgres by default).

Used as manageable alternative for Spring's `@Scheduled`.

## How to
1. Add dependency
```
implementation project(":spring-workers-manager-agent")
```
1. Define a job by annotations to be scheduled in any component.
```
package ru.touchin

@Component
class MyJob {

@ScheduledAction
@DefaultTrigger(type = "CRON", expression = "0 15 * * * ?")
fun sayHello(){
println("Hello, world!")
}

}
```

1. Enable job in `application.properties`

```
workers.names=ru.touchin.MyJob
```
or:
```
workers.names=*
```

1. Start the application.

## Annotations
### @ScheduledAction
Registers method as action of some job.

Parameters:
- `name` - name of job. Defaults to class full name.
Must be unique in application scope.

### @Trigger
Declares default trigger for the job.
Default triggers are created when launching job first time.

Parameters:
- `name` - Optional name for trigger. Defaults to some (maybe random) string.
Name must be unique in scope of corresponding job.
- `type` - Trigger type. See triggers types.
SpEL expressions are supported like in `@Scheduled` annotation of Spring.
- `expression` - The value for trigger.
SpEL expressions are supported like in `@Scheduled` annotation of Spring.

## Configuration
### Enabling workers

Agent ignores workers by default. To enable worker add its name to `worker.names` property.
Example:
```
worker.names=com.eample.Job1,\
com.example.Job2
```

#### Patterns for names
`workers.names` support Glob-like patterns.
- Asterisk (`*`) symbol is for "zero or more any symbols" (as `.*` in regex)
- Question mark (`?`) is for "any single symbol" (as `.` in regex)

## TODO
- External data source, unrelated to application code.
16 changes: 16 additions & 0 deletions spring-workers-manager-agent/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
id("kotlin")
id("kotlin-spring")
id("maven-publish")
}

dependencies {
implementation(project(":spring-workers-manager-core"))

implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")

implementation("org.springframework.data:spring-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-quartz")

testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package ru.touchin.spring.workers.manager.agent

import org.springframework.boot.context.event.ApplicationStartedEvent
import org.springframework.context.event.EventListener
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.stereotype.Component
import ru.touchin.spring.workers.manager.agent.config.WorkerInitializer
import ru.touchin.spring.workers.manager.agent.scheduled.WorkerManagerWatcher
import ru.touchin.spring.workers.manager.core.config.LiquibaseRunner

/**
* Prepares required resources and initializes agent.
*/
@Component
class AgentInitializer(
private val liquibase: LiquibaseRunner,
private val workerInitializer: WorkerInitializer,
private val workerWatcher: WorkerManagerWatcher
) {

@EventListener(value = [ApplicationStartedEvent::class])
@Order(Ordered.HIGHEST_PRECEDENCE + 500) // +500 is for "higher than any normal, but lower than any highest"
abuntakov marked this conversation as resolved.
Show resolved Hide resolved
fun execute() {
liquibase.run()
workerInitializer.init()
workerWatcher.init()
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package ru.touchin.spring.workers.manager.agent.annotation_config

import org.springframework.beans.factory.config.BeanPostProcessor
import org.springframework.stereotype.Component
import ru.touchin.spring.workers.manager.agent.annotation_config.job_factory.AnnotationConfigJobFactory
import ru.touchin.spring.workers.manager.agent.base.BaseJob
import java.lang.reflect.Method

/**
* 1. Scans components for [ScheduledAction] annotation
* 2. Keeps metadata of that components
* 3. Creates [BaseJob] for methods created
*/
@Component
class AnnotationConfigCollectingBeanPostProcessor(
private val jobFactories: List<AnnotationConfigJobFactory>
) : BeanPostProcessor {

val jobs: MutableList<BaseJob> = ArrayList()

val jobName2Method: MutableMap<String, Method> = HashMap()

/**
* Bean name -> class of this bean.
*
* Contains only entries for classes with [ScheduledAction] annotation.
*/
private val beanName2OriginalClass: MutableMap<String, Class<*>> = HashMap()

override fun postProcessBeforeInitialization(bean: Any, beanName: String): Any? {
val hasMethodsForScheduling = bean.javaClass.declaredMethods
.any { it.isAnnotationPresent(ScheduledAction::class.java) }

if (hasMethodsForScheduling) {
beanName2OriginalClass[beanName] = bean.javaClass
}

return bean
}

override fun postProcessAfterInitialization(bean: Any, beanName: String): Any? {
val clazz = beanName2OriginalClass[beanName]
?: return bean

val actionMethod = findActionMethod(clazz)

val createdJobs = jobFactories.flatMap { it.create(bean, actionMethod) }

createdJobs.forEach {
jobName2Method[it.getName()] = actionMethod
}

jobs.addAll(createdJobs)

return bean
}

companion object {

private fun findActionMethod(clazz: Class<*>): Method {
return clazz.declaredMethods
.filter { it.isAnnotationPresent(ScheduledAction::class.java) }
.also { annotatedMethods ->
check(annotatedMethods.size <= 1) {
"Class `${clazz.name}` has more that one methods with annotation @Scheduled. " +
"Methods: $annotatedMethods"
}
}
.onEach { annotatedMethod ->
check(annotatedMethod.parameters.isEmpty()) {
"Method ${clazz.name}:${annotatedMethod.name}' must not have arguments for scheduling, " +
"but requires ${annotatedMethod.parameters.size} parameters"
}
}
.single()
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ru.touchin.spring.workers.manager.agent.annotation_config

import org.springframework.stereotype.Component
import org.springframework.util.LinkedMultiValueMap
import org.springframework.util.MultiValueMap
import ru.touchin.spring.workers.manager.agent.annotation_config.trigger_factory.AnnotationConfigTriggerFactory
import ru.touchin.spring.workers.manager.agent.triggers.InitialTriggerDescriptorsProvider
import ru.touchin.spring.workers.manager.core.models.TriggerDescriptor
import ru.touchin.spring.workers.manager.core.models.Worker

@Component
class AnnotationConfigInitialTriggerDescriptorsProvider(
private val triggersCollector: AnnotationConfigCollectingBeanPostProcessor,
private val triggerFactories: List<AnnotationConfigTriggerFactory>
) : InitialTriggerDescriptorsProvider {

val jobName2Triggers: MultiValueMap<String, TriggerDescriptor> = LinkedMultiValueMap()

override fun applicableFor(worker: Worker): Boolean {
val actionMethod = triggersCollector.jobName2Method[worker.workerName]
?: return false

val triggers = triggerFactories.flatMap { it.create(worker, actionMethod) }

if (triggers.isEmpty()) {
return false
}

jobName2Triggers.addAll(worker.workerName, triggers)

return true
}

override fun createInitialTriggerDescriptors(worker: Worker): List<TriggerDescriptor> {
return jobName2Triggers[worker.workerName].orEmpty()
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package ru.touchin.spring.workers.manager.agent.annotation_config

import org.springframework.stereotype.Component
import ru.touchin.spring.workers.manager.agent.base.BaseJob
import ru.touchin.spring.workers.manager.agent.registry.JobProvider

@Component
class AnnotationConfigJobProvider(
private val jobsCollector: AnnotationConfigCollectingBeanPostProcessor
) : JobProvider {

override fun getJobs(): List<BaseJob> = jobsCollector.jobs

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package ru.touchin.spring.workers.manager.agent.annotation_config

import java.lang.annotation.Inherited

/**
* Adds default trigger to [ScheduledAction].
* Default trigger is submitted if job is launched first time.
*/
@Inherited
@Target(AnnotationTarget.FUNCTION)
annotation class DefaultTrigger(
abuntakov marked this conversation as resolved.
Show resolved Hide resolved
val name: String = "",
val type: String,
val expression: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package ru.touchin.spring.workers.manager.agent.annotation_config

@Target(AnnotationTarget.FUNCTION)
annotation class ScheduledAction(
/**
* Job name. Defaults to class name.
*/
val name: String = ""
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package ru.touchin.spring.workers.manager.agent.annotation_config.job_factory

import ru.touchin.spring.workers.manager.agent.base.BaseJob
import java.lang.reflect.Method

/**
* Invoked when found method with [ScheduledAction] annotation in some bean.
*
* Used to read jobs settings from annotations in components.
*/
interface AnnotationConfigJobFactory {

/**
* Warning: As Spring could substitute actual beans with proxy-objects,
* you must carefully check if [actionMethod] is applicable for [bean].
*/
fun create(bean: Any, actionMethod: Method): List<BaseJob>

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package ru.touchin.spring.workers.manager.agent.annotation_config.job_factory

import org.springframework.stereotype.Component
import ru.touchin.spring.workers.manager.agent.annotation_config.ScheduledAction
import ru.touchin.spring.workers.manager.agent.base.BaseJob
import java.lang.reflect.Method

/**
* Creates job instances for every annotated action method.
*/
@Component
class ScheduledActionAnnotationConfigJobFactory
: AnnotationConfigJobFactory {

override fun create(bean: Any, actionMethod: Method): List<BaseJob> {
val job = createJobForBean(bean, actionMethod)

return listOf(job)
}

companion object {

private fun createJobForBean(bean: Any, annotatedMethod: Method): BaseJob {
val targetMethod = bean.javaClass.getMethod(annotatedMethod)
val annotation = annotatedMethod.getAnnotation(ScheduledAction::class.java)

val jobName: String = annotation.name.takeIf { it.isNotBlank() }
?: annotatedMethod.declaringClass.name

return createJob(jobName) { targetMethod.invoke(bean) }
}

private fun Class<*>.getMethod(sampleMethod: Method): Method {
return getMethod(sampleMethod.name, *sampleMethod.parameterTypes)
.apply { isAccessible = true }
}

private fun createJob(jobName: String, func: () -> Unit): BaseJob = object : BaseJob {

override fun getName() = jobName

override fun run() {
func.invoke()
}

}

}

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package ru.touchin.spring.workers.manager.agent.annotation_config.trigger_factory

import ru.touchin.spring.workers.manager.core.models.TriggerDescriptor
import ru.touchin.spring.workers.manager.core.models.Worker
import java.lang.reflect.Method

/**
* Used to create initial triggers for new workers
*/
interface AnnotationConfigTriggerFactory {

fun create(worker: Worker, actionMethod: Method): List<TriggerDescriptor>

}
Loading