conflow 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -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
@@ -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