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.
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