conflow 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.codeclimate.yml +6 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +19 -0
- data/.travis.yml +17 -0
- data/CHANGELOG.md +3 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +69 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/Rakefile +8 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/conflow.gemspec +32 -0
- data/lib/conflow.rb +49 -0
- data/lib/conflow/flow.rb +60 -0
- data/lib/conflow/flow/job_handler.rb +70 -0
- data/lib/conflow/job.rb +37 -0
- data/lib/conflow/redis/add_job_script.rb +36 -0
- data/lib/conflow/redis/array_field.rb +81 -0
- data/lib/conflow/redis/complete_job_script.rb +32 -0
- data/lib/conflow/redis/connection_wrapper.rb +19 -0
- data/lib/conflow/redis/field.rb +46 -0
- data/lib/conflow/redis/field_builder.rb +44 -0
- data/lib/conflow/redis/findable.rb +28 -0
- data/lib/conflow/redis/hash_field.rb +74 -0
- data/lib/conflow/redis/identifier.rb +66 -0
- data/lib/conflow/redis/model.rb +55 -0
- data/lib/conflow/redis/script.rb +70 -0
- data/lib/conflow/redis/sorted_set_field.rb +141 -0
- data/lib/conflow/redis/value_field.rb +50 -0
- data/lib/conflow/version.rb +6 -0
- data/lib/conflow/worker.rb +32 -0
- metadata +190 -0
data/lib/conflow.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "conflow/version"
|
4
|
+
|
5
|
+
require "digest"
|
6
|
+
require "json"
|
7
|
+
require "redis"
|
8
|
+
|
9
|
+
require "conflow/redis/connection_wrapper"
|
10
|
+
require "conflow/redis/field"
|
11
|
+
require "conflow/redis/value_field"
|
12
|
+
require "conflow/redis/hash_field"
|
13
|
+
require "conflow/redis/array_field"
|
14
|
+
require "conflow/redis/sorted_set_field"
|
15
|
+
require "conflow/redis/field_builder"
|
16
|
+
require "conflow/redis/model"
|
17
|
+
require "conflow/redis/identifier"
|
18
|
+
require "conflow/redis/findable"
|
19
|
+
|
20
|
+
require "conflow/redis/script"
|
21
|
+
require "conflow/redis/add_job_script"
|
22
|
+
require "conflow/redis/complete_job_script"
|
23
|
+
|
24
|
+
require "conflow/job"
|
25
|
+
require "conflow/flow/job_handler"
|
26
|
+
require "conflow/flow"
|
27
|
+
require "conflow/worker"
|
28
|
+
|
29
|
+
# Conflow allows defining comlicated workflows with dependencies.
|
30
|
+
# Inspired by {https://github.com/chaps-io/gush Gush} (the idea) and
|
31
|
+
# {https://github.com/nateware/redis-objects Redis::Objects} (the implementation) it focuses solely on dependency logic,
|
32
|
+
# while leaving queueing jobs and executing them entirely in hands of the programmer.
|
33
|
+
module Conflow
|
34
|
+
class << self
|
35
|
+
def redis=(conn)
|
36
|
+
@redis =
|
37
|
+
if defined?(ConnectionPool) && conn.is_a?(ConnectionPool)
|
38
|
+
conn
|
39
|
+
else
|
40
|
+
Conflow::Redis::ConnectionWrapper.new(conn)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def redis
|
45
|
+
self.redis = ::Redis.current unless defined?(@redis)
|
46
|
+
@redis
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
data/lib/conflow/flow.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Conflow
|
4
|
+
# Flow is a set of steps needed to complete certain task. It is composed of jobs
|
5
|
+
# which have dependency relations with one another.
|
6
|
+
#
|
7
|
+
# {Conflow::Flow} class is designed to be inherited in your application. You must supply {queue} method
|
8
|
+
#
|
9
|
+
# @!attribute [r] jobs
|
10
|
+
# Read-only array of jobs added to the flow.
|
11
|
+
# @return [Array<Conflow::Job>] List of jobs in the flow
|
12
|
+
#
|
13
|
+
# @!attribute [rw] indegree
|
14
|
+
# Sorted set (Redis zset) of job ids. Each job has a score attached, which is the number of "indegree" nodes -
|
15
|
+
# the nodes on which given job depends. This changes dynamically and score equal to 0 means that all dependencies
|
16
|
+
# are fulfilled.
|
17
|
+
# @return [Conflow::Redis::SortedSetField] Set of jobs to be performed
|
18
|
+
#
|
19
|
+
# @!method queue(job)
|
20
|
+
# @abstract
|
21
|
+
# Queues job to be performed. Both id of the flow and id of the job must be preserved
|
22
|
+
# in order to recreate job in worker.
|
23
|
+
# @param job [Conflow::Job] job to be queued
|
24
|
+
#
|
25
|
+
# @example Queue sidekiq job
|
26
|
+
# class MyBaseFlow < Conflow::Flow
|
27
|
+
# def queue(job)
|
28
|
+
# Sidekiq::Client.enqueue(FlowWorkerJob, id, job.id)
|
29
|
+
# end
|
30
|
+
# end
|
31
|
+
class Flow < Conflow::Redis::Field
|
32
|
+
include Conflow::Redis::Model
|
33
|
+
include Conflow::Redis::Identifier
|
34
|
+
include Conflow::Redis::Findable
|
35
|
+
include JobHandler
|
36
|
+
|
37
|
+
has_many :jobs, Conflow::Job
|
38
|
+
field :indegree, :sorted_set
|
39
|
+
|
40
|
+
# Create new flow with given parameters
|
41
|
+
# @example Simple configurable flow
|
42
|
+
# class MyFlow < Conflow::Flow
|
43
|
+
# def configure(id:, strict:)
|
44
|
+
# run UpsertJob, params: { id: id }
|
45
|
+
# run CheckerJob, params: { id: id } if strict
|
46
|
+
# end
|
47
|
+
# end
|
48
|
+
#
|
49
|
+
# MyFlow.create(id: 320, strict: false)
|
50
|
+
# MyFlow.create(id: 15, strict: true)
|
51
|
+
def self.create(*args)
|
52
|
+
new.tap { |flow| flow.configure(*args) }
|
53
|
+
end
|
54
|
+
|
55
|
+
# @abstract
|
56
|
+
# Override this method in order to contain your flow definition inside the class.
|
57
|
+
# This method will be called if flow is created using {.create} method.
|
58
|
+
def configure(*args); end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Conflow
|
4
|
+
class Flow < Conflow::Redis::Field
|
5
|
+
# Handles running and finishing jobs
|
6
|
+
module JobHandler
|
7
|
+
# @param job_class [Class] Class of the worker which will perform the job
|
8
|
+
# @param params [Hash]
|
9
|
+
# Parameters of the job. They will be passed to {Conflow::Worker#perform} block. Defalts to empty hash.
|
10
|
+
# @param after [Conflow::Job|Class|Integer|Array<Conflow::Job,Class,Integer>]
|
11
|
+
# Dependencies of the job. Can be one or more objects of the following classes: {Conflow::Job}, Class, Integer
|
12
|
+
# @param hook [Symbol] method to be called on {Conflow::Flow} instance after job is performed.
|
13
|
+
# The hook method should accept result of the job (value returned by {Conflow::Worker#perform})
|
14
|
+
# @return [Conflow::Job] enqueued job
|
15
|
+
def run(job_class, params: {}, after: [], hook: nil)
|
16
|
+
build_job(job_class, params, hook).tap do |job|
|
17
|
+
job_classes[job_class] = job
|
18
|
+
after = prepare_dependencies(after)
|
19
|
+
|
20
|
+
call_script(Conflow::Redis::AddJobScript, job, after: after)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def finish(job, result = nil)
|
25
|
+
send(job.hook.to_s, result) unless job.hook.nil?
|
26
|
+
call_script(Conflow::Redis::CompleteJobScript, job)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def queue_available_jobs
|
32
|
+
indegree.delete_if(score: 0).each do |job_id|
|
33
|
+
queue Conflow::Job.new(job_id)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def build_job(job_class, params, hook)
|
38
|
+
Conflow::Job.new.tap do |job|
|
39
|
+
job.params = params if params.any?
|
40
|
+
job.hook = hook if hook
|
41
|
+
job.class_name = job_class.name
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def call_script(script, *args)
|
46
|
+
script.call(self, *args)
|
47
|
+
queue_available_jobs
|
48
|
+
end
|
49
|
+
|
50
|
+
def prepare_dependencies(dependencies)
|
51
|
+
case dependencies
|
52
|
+
when Enumerable then dependencies.map(&method(:prepare_dependency))
|
53
|
+
else [prepare_dependency(dependencies)]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def prepare_dependency(dependency)
|
58
|
+
case dependency
|
59
|
+
when Conflow::Job then dependency
|
60
|
+
when Class then job_classes[dependency]
|
61
|
+
when String, Numeric then Conflow::Job.new(dependency)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def job_classes
|
66
|
+
@job_classes ||= {}
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/conflow/job.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Conflow
|
4
|
+
# Represents conflow job.
|
5
|
+
# @!attribute [rw] status
|
6
|
+
# Status of the job
|
7
|
+
# @return [Integer] 0 - pending, 1 - finished
|
8
|
+
# @!attribute [rw] hook
|
9
|
+
# @return [String, nil] name of the method on related flow to be called once job is finished
|
10
|
+
# @!attribute [rw] class_name
|
11
|
+
# @return [String] class name of the worker class
|
12
|
+
# @!attribute [rw] params
|
13
|
+
# @return [Hash, nil] parameters needed to complete job
|
14
|
+
class Job < Conflow::Redis::Field
|
15
|
+
include Conflow::Redis::Model
|
16
|
+
include Conflow::Redis::Identifier
|
17
|
+
|
18
|
+
has_many :successors, Conflow::Job
|
19
|
+
field :params, :hash
|
20
|
+
field :class_name, :value
|
21
|
+
field :status, :value # 0 - pending, 1 - finished
|
22
|
+
field :hook, :value
|
23
|
+
|
24
|
+
# Returns instance of Job. It sets status to 0 (pending) for new jobs
|
25
|
+
def initialize(*)
|
26
|
+
super
|
27
|
+
status.default(0)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Convienience method returning Class object of the job.
|
31
|
+
# It's the class supplied in {Conflow::Flow#run} method
|
32
|
+
# @return [Class] class of the job
|
33
|
+
def worker_type
|
34
|
+
Object.const_get(class_name.to_s)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Conflow
|
4
|
+
module Redis
|
5
|
+
# Adds new job to flow
|
6
|
+
class AddJobScript < Script
|
7
|
+
# script accepts keys: flow.job_ids, flow.indegree, and keys successors of the jobs on which new job depends.
|
8
|
+
# It also accepts one argument: id of the new job
|
9
|
+
self.script = <<~LUA
|
10
|
+
local job_list = KEYS[1]
|
11
|
+
local indegree_set = KEYS[2]
|
12
|
+
local job_id = ARGV[1]
|
13
|
+
local score = 0
|
14
|
+
|
15
|
+
for i=3,#KEYS do
|
16
|
+
if redis.call('get', KEYS[i] .. ':status') == '0' then
|
17
|
+
score = score + 1
|
18
|
+
redis.call('lpush', KEYS[i] .. ':successor_ids', job_id)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
redis.call('lpush', job_list, job_id)
|
23
|
+
return redis.call('zadd', indegree_set, score, job_id)
|
24
|
+
LUA
|
25
|
+
|
26
|
+
class << self
|
27
|
+
# Call the script.
|
28
|
+
# Script changes {Conflow::Flow#indegree} of all of its successors by -1 (freeing them to be queued if it
|
29
|
+
# reaches 0) and sets {Conflow::Job#status} to 1 (finished)
|
30
|
+
def call(flow, job, after: [])
|
31
|
+
super([flow.job_ids.key, flow.indegree.key, *after.map(&:key)], [job.id])
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Conflow
|
4
|
+
module Redis
|
5
|
+
# Represents Redis list. It's methods mirror most used Array methods.
|
6
|
+
class ArrayField < Field
|
7
|
+
include Enumerable
|
8
|
+
|
9
|
+
def [](index)
|
10
|
+
command :lindex, [key, index]
|
11
|
+
end
|
12
|
+
|
13
|
+
def []=(index, value)
|
14
|
+
command :lset, [key, index, value]
|
15
|
+
end
|
16
|
+
|
17
|
+
def insert(value, after: nil, before: nil)
|
18
|
+
if after
|
19
|
+
command :linsert, [key, :after, after, value]
|
20
|
+
elsif before
|
21
|
+
command :linsert, [key, :before, before, value]
|
22
|
+
else
|
23
|
+
raise ArgumentError, "You need to pass one of [:after, :before] keywords"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def size
|
28
|
+
command :llen, [key]
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_a
|
32
|
+
command :lrange, [key, 0, -1]
|
33
|
+
end; alias to_ary to_a
|
34
|
+
|
35
|
+
def pop
|
36
|
+
command :rpop, [key]
|
37
|
+
end
|
38
|
+
|
39
|
+
def push(*values)
|
40
|
+
command :rpush, [key, values]
|
41
|
+
end; alias << push
|
42
|
+
|
43
|
+
def concat(ary)
|
44
|
+
push(*ary)
|
45
|
+
end
|
46
|
+
|
47
|
+
def shift
|
48
|
+
command :lpop, [key]
|
49
|
+
end
|
50
|
+
|
51
|
+
def unshift(value)
|
52
|
+
command :lpush, [key, value]
|
53
|
+
end
|
54
|
+
|
55
|
+
def overwrite(new_array)
|
56
|
+
redis.with do |conn|
|
57
|
+
conn.pipelined do
|
58
|
+
conn.del(key)
|
59
|
+
conn.rpush(key, new_array)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def each(&block)
|
65
|
+
to_a.each(&block)
|
66
|
+
end
|
67
|
+
|
68
|
+
def ==(other)
|
69
|
+
case other
|
70
|
+
when Array then to_a == other
|
71
|
+
when ArrayField then key == other.key || to_a == other.to_a
|
72
|
+
else super
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def to_s
|
77
|
+
to_a.to_s
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Conflow
|
4
|
+
module Redis
|
5
|
+
# Adds new job to flow
|
6
|
+
class CompleteJobScript < Script
|
7
|
+
self.script = <<~LUA
|
8
|
+
local indegree_set = KEYS[1]
|
9
|
+
local job_id = KEYS[2]
|
10
|
+
|
11
|
+
local successors = redis.call('lrange', job_id .. ':successor_ids', 0, -1)
|
12
|
+
|
13
|
+
for i=1,#successors do
|
14
|
+
redis.call('zincrby', indegree_set, -1, successors[i])
|
15
|
+
end
|
16
|
+
|
17
|
+
return redis.call('set', job_id .. ':status', '1')
|
18
|
+
LUA
|
19
|
+
|
20
|
+
class << self
|
21
|
+
# Call the script.
|
22
|
+
# Script changes {Conflow::Flow#indegree} of all of its successors by -1 (freeing them to be queued if it
|
23
|
+
# reaches 0) and sets {Conflow::Job#status} to 1 (finished)
|
24
|
+
# @param flow [Conflow::Flow] Flow to which job belongs to
|
25
|
+
# @param job [Conflow::Job] Job to be marked as completed
|
26
|
+
def call(flow, job)
|
27
|
+
super([flow.indegree.key, job.key])
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Conflow
|
4
|
+
module Redis
|
5
|
+
# Wraps Redis connection to behave like connection pool
|
6
|
+
class ConnectionWrapper
|
7
|
+
# @param redis [Redis] Redis connection to be wrapped
|
8
|
+
def initialize(redis)
|
9
|
+
@redis = redis
|
10
|
+
end
|
11
|
+
|
12
|
+
# Allows accessing Redis connection
|
13
|
+
# @yieldparam [Redis] redis connection
|
14
|
+
def with
|
15
|
+
yield @redis
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Conflow
|
4
|
+
module Redis
|
5
|
+
# Base class for fields. All fields are assigned a Redis key.
|
6
|
+
class Field
|
7
|
+
attr_reader :key
|
8
|
+
alias id key
|
9
|
+
|
10
|
+
def initialize(key)
|
11
|
+
@key = key
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def redis
|
17
|
+
Conflow.redis
|
18
|
+
end
|
19
|
+
|
20
|
+
def command(command, args)
|
21
|
+
redis.with { |conn| conn.send(command, *args) }
|
22
|
+
end
|
23
|
+
|
24
|
+
def transaction(max_retries: 5)
|
25
|
+
result = redis.with do |conn|
|
26
|
+
retryable(max_retries, conn) do
|
27
|
+
conn.watch(key) do
|
28
|
+
conn.multi { |multi| yield(multi) }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
raise ::Redis::CommandError, "Transaction failed #{max_retries} times" unless result
|
34
|
+
result
|
35
|
+
end
|
36
|
+
|
37
|
+
def retryable(max_retries, *args)
|
38
|
+
loop do
|
39
|
+
result = yield(*args)
|
40
|
+
break result if result || max_retries.zero?
|
41
|
+
max_retries -= 1
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Conflow
|
4
|
+
module Redis
|
5
|
+
# Helper class for defining getter and setter methods for Fields
|
6
|
+
class FieldBuilder
|
7
|
+
# This dynamic module contains accessor methods for a field
|
8
|
+
class FieldAccessor < Module
|
9
|
+
def initialize(field_name, klass, methods)
|
10
|
+
super() do
|
11
|
+
define_getter(field_name, klass) if methods.include?(:getter)
|
12
|
+
define_setter(field_name) if methods.include?(:setter)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def define_getter(field_name, klass)
|
17
|
+
instance_var = "@#{field_name}"
|
18
|
+
|
19
|
+
define_method(field_name) do
|
20
|
+
instance_variable_get(instance_var) ||
|
21
|
+
instance_variable_set(instance_var, klass.new([key, field_name].join(":")))
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def define_setter(field_name)
|
26
|
+
define_method("#{field_name}=") do |value|
|
27
|
+
send(field_name).tap { |field| field.overwrite(value) }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
attr_reader :field_name, :klass
|
33
|
+
|
34
|
+
def initialize(field_name, klass)
|
35
|
+
@field_name = field_name
|
36
|
+
@klass = klass
|
37
|
+
end
|
38
|
+
|
39
|
+
def call(base, methods: %i[getter setter])
|
40
|
+
base.include(FieldAccessor.new(field_name, klass, methods))
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|