conflow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|