concurrent_pipeline 1.0.0 → 2.0.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.
@@ -30,7 +30,9 @@ Gem::Specification.new do |spec|
30
30
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
31
  spec.require_paths = ["lib"]
32
32
 
33
- spec.add_dependency("zeitwerk")
34
- spec.add_dependency("yaml")
35
- spec.add_dependency("async")
33
+ spec.add_dependency("zeitwerk", "~> 2.7")
34
+ spec.add_dependency("yaml", "~> 0.4")
35
+ spec.add_dependency("async", "~> 2.35")
36
+ spec.add_dependency("activerecord", "~> 8.0")
37
+ spec.add_dependency("sqlite3", "~> 2.4")
36
38
  end
@@ -0,0 +1,6 @@
1
+ module ConcurrentPipeline
2
+ module Errors
3
+ Base = Class.new(StandardError)
4
+ AssertionFailure = Class.new(Base)
5
+ end
6
+ end
@@ -6,22 +6,18 @@ module ConcurrentPipeline
6
6
  schema = Pipelines::Schema.new
7
7
  schema.instance_exec(&block)
8
8
 
9
- new(schema)
9
+ Class.new(Pipeline) do
10
+ define_singleton_method(:process) { |store| new(schema).process(store) }
11
+ end
10
12
  end
11
13
 
12
- attr_reader :schema, :processor
14
+ attr_reader :schema
13
15
  def initialize(schema)
14
16
  @schema = schema
15
- @processor = nil
16
17
  end
17
18
 
18
19
  def process(store)
19
- @processor = schema.build_processor(store)
20
- @processor.call
21
- end
22
-
23
- def errors
24
- @processor&.errors || []
20
+ schema.build_processor(store).call
25
21
  end
26
22
  end
27
23
  end
@@ -9,56 +9,110 @@ module ConcurrentPipeline
9
9
  new(...).call
10
10
  end
11
11
 
12
- attr_reader(:store, :producers, :locker, :concurrency, :enqueue_seconds, :errors)
13
- def initialize(store:, producers:, concurrency: 5, enqueue_seconds: 0.1)
12
+ attr_reader(
13
+ :store,
14
+ :producers,
15
+ :locker,
16
+ :concurrency,
17
+ :enqueue_seconds,
18
+ :errors,
19
+ :before_process_hooks,
20
+ :timers
21
+ )
22
+ def initialize(
23
+ store:,
24
+ producers:,
25
+ concurrency: 5,
26
+ enqueue_seconds: 0.1,
27
+ before_process_hooks: [],
28
+ timers: []
29
+ )
14
30
  @store = store
15
31
  @producers = producers
16
32
  @concurrency = concurrency
17
33
  @enqueue_seconds = enqueue_seconds
18
34
  @locker = Locker.new
19
35
  @errors = []
36
+ @before_process_hooks = before_process_hooks
37
+ @timers = timers
38
+ @completed = 0
39
+ @start_time = nil
20
40
  end
21
41
 
22
42
  def call
43
+ @start_time = Time.now
23
44
  Async { |task|
24
45
  semaphore = Async::Semaphore.new(concurrency)
25
46
  active_tasks = []
26
- result = true
27
47
 
28
- loop do
29
- # Set result to false if any task has failed
30
- if errors.any?
31
- result = false
32
- break
48
+ # Start timer tasks
49
+ timer_tasks = timers.map { |timer|
50
+ task.async do
51
+ loop do
52
+ sleep(timer.interval)
53
+ begin
54
+ stats = build_stats
55
+ timer.block.call(stats)
56
+ rescue => e
57
+ # Silently ignore timer errors to not break the pipeline
58
+ end
59
+ end
33
60
  end
61
+ }
62
+
63
+ begin
64
+ loop do
65
+ if errors.any?
66
+ break
67
+ end
34
68
 
35
- # Clean up finished tasks
36
- active_tasks.reject!(&:finished?)
69
+ # Clean up finished tasks
70
+ active_tasks.reject!(&:finished?)
37
71
 
38
- # Try to enqueue more work (only if no failure)
39
- enqueued_any = enqueue_all(semaphore, active_tasks, task)
72
+ # Try to enqueue more work (only if no failure)
73
+ enqueued_any = enqueue_all(semaphore, active_tasks, task)
40
74
 
41
- # Stop when nothing is being processed AND nothing new was enqueued
42
- break if active_tasks.empty? && !enqueued_any
75
+ # Stop when nothing is being processed AND nothing new was enqueued
76
+ break if active_tasks.empty? && !enqueued_any
43
77
 
44
- # Yield to allow other tasks to progress
45
- sleep(enqueue_seconds)
78
+ # Yield to allow other tasks to progress
79
+ sleep(enqueue_seconds)
80
+ end
81
+ ensure
82
+ # Stop all timer tasks
83
+ timer_tasks.each(&:stop)
46
84
  end
47
85
 
48
- result # Return false if there was a failure, true otherwise
86
+ Result.new(errors:)
49
87
  }.wait # Wait for the async block to complete and return its value
50
88
  end
51
89
 
90
+ def queue_size
91
+ locker.locks.size
92
+ end
93
+
94
+ private
95
+
96
+ def build_stats
97
+ Pipelines::Schema::Stats.new(
98
+ queue_size: queue_size,
99
+ completed: @completed,
100
+ time: Time.now - @start_time
101
+ )
102
+ end
103
+
52
104
  def enqueue_all(semaphore, active_tasks, parent_task)
53
105
  enqueued_any = false
54
106
 
55
107
  producers.each do |producer|
56
- producer.records(store).each do |record|
108
+ records = producer.records(store)
109
+ records.each_with_index do |record, index|
57
110
  next if locker.locked?(producer:, record:)
58
111
 
59
112
  enqueued_any = true
60
113
  locker.lock(producer:, record:)
61
114
 
115
+
62
116
  # Spawn async task
63
117
  new_task = parent_task.async do
64
118
  begin
@@ -67,9 +121,18 @@ module ConcurrentPipeline
67
121
 
68
122
  semaphore.acquire do
69
123
  begin
124
+
125
+ before_process_hooks.each do |hook|
126
+ Pipelines::Schema::Step.new(
127
+ value: record,
128
+ label: producer.label
129
+ ).then { hook.call(_1) }
130
+ end
131
+
70
132
  store.transaction do
71
133
  producer.call(record)
72
134
  end
135
+ @completed += 1
73
136
  rescue => e
74
137
  # Append error to array to prevent async gem from logging it
75
138
  errors << e
@@ -12,15 +12,15 @@ module ConcurrentPipeline
12
12
  end
13
13
 
14
14
  def locked?(producer:, record:)
15
- locks.key?([producer, record.class.record_name, record.id])
15
+ locks.key?([producer, record.class.name, record.id])
16
16
  end
17
17
 
18
18
  def lock(producer:, record:)
19
- locks[[producer, record.class.record_name, record.id]] = true
19
+ locks[[producer, record.class.name, record.id]] = true
20
20
  end
21
21
 
22
22
  def unlock(producer:, record:)
23
- locks.delete([producer, record.class.record_name, record.id])
23
+ locks.delete([producer, record.class.name, record.id])
24
24
  end
25
25
  end
26
26
  end
@@ -0,0 +1,11 @@
1
+ module ConcurrentPipeline
2
+ module Pipelines
3
+ module Processors
4
+ Result = Data.define(:errors) do
5
+ def success?
6
+ errors.empty?
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -1,3 +1,5 @@
1
+ require "async"
2
+
1
3
  module ConcurrentPipeline
2
4
  module Pipelines
3
5
  module Processors
@@ -6,33 +8,100 @@ module ConcurrentPipeline
6
8
  new(...).call
7
9
  end
8
10
 
9
- attr_reader(:store, :producers, :locker, :errors)
10
- def initialize(store:, producers:)
11
+ attr_reader(
12
+ :store,
13
+ :producers,
14
+ :locker,
15
+ :errors,
16
+ :before_process_hooks,
17
+ :timers
18
+ )
19
+ def initialize(
20
+ store:,
21
+ producers:,
22
+ before_process_hooks: [],
23
+ timers: []
24
+ )
11
25
  @store = store
12
26
  @producers = producers
13
27
  @locker = Locker.new
14
28
  @errors = []
29
+ @before_process_hooks = before_process_hooks
30
+ @timers = timers
31
+ @completed = 0
32
+ @start_time = nil
15
33
  end
16
34
 
17
35
  def call
18
- while(enqueue_all) do end
19
- errors.empty?
36
+ @start_time = Time.now
37
+ Async { |task|
38
+ # Start timer tasks
39
+ timer_tasks = timers.map { |timer|
40
+ task.async do
41
+ loop do
42
+ sleep(timer.interval)
43
+ begin
44
+ stats = build_stats
45
+ timer.block.call(stats)
46
+ rescue => e
47
+ # Silently ignore timer errors to not break the pipeline
48
+ end
49
+ end
50
+ end
51
+ }
52
+
53
+ begin
54
+ while(enqueue_all) do end
55
+ Result.new(errors:)
56
+ ensure
57
+ timer_tasks.each(&:stop)
58
+ end
59
+ }.wait
60
+ end
61
+
62
+ def queue_size
63
+ # Cannot use locker.locks because don't enqueue them all, just
64
+ # process records one-by-one.
65
+ producers.sum do |producer|
66
+ producer.records(store).count do |record|
67
+ !locker.locked?(producer:, record:)
68
+ end
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def build_stats
75
+ Pipelines::Schema::Stats.new(
76
+ queue_size: queue_size,
77
+ completed: @completed,
78
+ time: Time.now - @start_time
79
+ )
20
80
  end
21
81
 
22
82
  def enqueue_all
23
83
  enqueued_any = false
24
84
 
25
85
  producers.each do |producer|
26
- producer.records(store).each do |record|
86
+ records = producer.records(store)
87
+ records.each_with_index do |record, index|
27
88
  next if locker.locked?(producer:, record:)
28
89
 
29
90
  enqueued_any = true
30
91
  locker.lock(producer:, record:)
31
92
 
32
93
  begin
94
+ before_process_hooks.each do |hook|
95
+ Pipelines::Schema::Step.new(
96
+ value: record,
97
+ label: producer.label
98
+ ).then { hook.call(_1) }
99
+ end
100
+
33
101
  store.transaction do
34
102
  producer.call(record)
35
103
  end
104
+ @completed += 1
36
105
  rescue => e
37
106
  errors << e
38
107
  return false
@@ -8,11 +8,19 @@ module ConcurrentPipeline
8
8
  async: Processors::Asynchronous,
9
9
  }
10
10
 
11
- Producer = Struct.new(:query, :block) do
11
+ Step = Struct.new(:value, :queue_size, :label, keyword_init: true)
12
+ Timer = Data.define(:interval, :block)
13
+ Stats = Struct.new(:queue_size, :completed, :time, keyword_init: true)
14
+
15
+ Producer = Struct.new(:query, :block, :label, keyword_init: true) do
12
16
  def call(*a, **p)
13
17
  instance_exec(*a, **p, &block)
14
18
  end
15
19
 
20
+ def assert(val)
21
+ raise Errors::AssertionFailure.new("Post condition failed") unless val
22
+ end
23
+
16
24
  def shell
17
25
  Shell
18
26
  end
@@ -20,9 +28,10 @@ module ConcurrentPipeline
20
28
  def records(store)
21
29
  if query.is_a?(Proc)
22
30
  query.call
31
+ elsif query.is_a?(ActiveRecord::Relation)
32
+ query.reload.to_a
23
33
  else
24
- # Query is a hash with record_name and filters
25
- store.where(query[:record_name], **query[:filters])
34
+ raise "Invalid processor query type: #{query.inspect}"
26
35
  end
27
36
  end
28
37
  end
@@ -31,6 +40,18 @@ module ConcurrentPipeline
31
40
  @producers ||= []
32
41
  end
33
42
 
43
+ def arounds
44
+ @arounds ||= []
45
+ end
46
+
47
+ def timers
48
+ @timers ||= []
49
+ end
50
+
51
+ def before_process_hooks
52
+ @before_process_hooks ||= []
53
+ end
54
+
34
55
  def processor(type, **attrs)
35
56
  @processor = {type:, attrs:}
36
57
  end
@@ -38,18 +59,23 @@ module ConcurrentPipeline
38
59
  def build_processor(store)
39
60
  PROCESSORS
40
61
  .fetch(@processor.fetch(:type))
41
- .new(store:, producers:, **@processor.fetch(:attrs))
62
+ .new(store:, producers:, before_process_hooks:, timers:, **@processor.fetch(:attrs))
42
63
  end
43
64
 
44
- def process(query_or_record_name, **filters, &block)
45
- if query_or_record_name.is_a?(Proc)
46
- # Lambda-based query (current behavior)
47
- producers << Producer.new(query: query_or_record_name, block:)
48
- else
49
- # Record name with filters
50
- query = { record_name: query_or_record_name, filters: }
51
- producers << Producer.new(query:, block:)
52
- end
65
+ def process(query, label: nil, &block)
66
+ producers << Producer.new(query:, block:, label:)
67
+ end
68
+
69
+ def around(&block)
70
+ arounds << block
71
+ end
72
+
73
+ def before_process(&block)
74
+ before_process_hooks << block
75
+ end
76
+
77
+ def timer(interval, &block)
78
+ timers << Timer.new(interval:, block:)
53
79
  end
54
80
  end
55
81
  end
@@ -1,4 +1,4 @@
1
- require "securerandom"
1
+ require "active_record"
2
2
 
3
3
  module ConcurrentPipeline
4
4
  class Store
@@ -10,93 +10,196 @@ module ConcurrentPipeline
10
10
  define_method(:schema) { schema }
11
11
  end
12
12
 
13
+ schema.records.each do |name, spec|
14
+ define_method(name) { class_for(spec) }
15
+ end
13
16
 
14
- klass.new(schema.storage)
17
+ klass.new(schema:, version: :root)
15
18
  end
16
19
 
17
- attr_reader :storage
18
- def initialize(storage)
19
- @storage = storage
20
+ attr_reader :schema, :version
21
+ def initialize(schema:, version:)
22
+ @schema = schema
23
+ @version = version
24
+ @klasses = {}
25
+
26
+ # eagerly construct classes
27
+ schema.records.each_value { class_for(_1) }
20
28
  end
21
29
 
22
- def transaction(&block)
23
- ensure_writable
30
+ def transaction(&)
31
+ base_class.transaction(&)
32
+ end
33
+
34
+ def versions
35
+ return [self] unless root?
36
+
37
+ version_files = Dir.glob(File.join(schema.dir, "versions", "*.sqlite3")).sort
38
+ version_files.map do |file|
39
+ version_num = File.basename(file, ".sqlite3").to_i
40
+ Store.new(schema: schema, version: version_num)
41
+ end
42
+ end
24
43
 
25
- if storage.in_transaction?
26
- raise "Nested transactions are not supported"
44
+ def restore
45
+ raise "Can only restore from a version snapshot, not from root" if root?
46
+
47
+ # Copy the version database to the main database
48
+ main_db = File.join(schema.dir, "db.sqlite3")
49
+ FileUtils.cp(db_path, main_db)
50
+
51
+ # Renumber versions: keep all versions up to and including this one,
52
+ # then create a new version snapshot for the restore
53
+ version_files = Dir.glob(File.join(schema.dir, "versions", "*.sqlite3")).sort
54
+ version_files.each do |file|
55
+ file_version = File.basename(file, ".sqlite3").to_i
56
+ if file_version > version
57
+ FileUtils.rm(file)
58
+ end
27
59
  end
28
60
 
29
- storage.transaction(&block)
61
+ # Create a new version snapshot of the restored state
62
+ new_version_num = version + 1
63
+ version_file = File.join(schema.dir, "versions", "#{new_version_num}.sqlite3")
64
+ FileUtils.cp(main_db, version_file)
30
65
 
31
- nil
66
+ # Return a new root store for the restored state
67
+ Store.new(schema: schema, version: :root)
32
68
  end
33
69
 
34
- def create(record_name, **attrs)
35
- ensure_writable
70
+ private
36
71
 
37
- storage.create(
38
- name: record_name,
39
- attrs: { id: SecureRandom.uuid }.merge(attrs)
40
- )
72
+ def schema_root_mod_name
73
+ @schema_root_mod_name ||+ "SchemaRoot_#{schema.object_id}_#{object_id}"
74
+ end
41
75
 
42
- nil
76
+ def schema_root_mod
77
+ @schema_root_mod ||= (
78
+ if Store.const_defined?(schema_root_mod_name)
79
+ Store.const_get(schema_root_mod_name)
80
+ else
81
+ Module.new.tap { Store.const_set(schema_root_mod_name, _1) }
82
+ end
83
+ )
43
84
  end
44
85
 
45
- def update(record, **attrs)
46
- ensure_writable
86
+ def base_class
87
+ @base_class ||= (
88
+ me = self
89
+ klass = Class.new(ActiveRecord::Base) do
90
+ define_singleton_method(:transaction_mutex) do
91
+ @transaction_mutex ||= Mutex.new
92
+ end
47
93
 
48
- # Create a temporary record to apply and validate setter methods
49
- temp_record = record.class.new(record.attributes)
94
+ define_singleton_method(:class_name) do |name|
95
+ "#{me.send(:schema_root_mod)}::Record_#{name}"
96
+ end
50
97
 
51
- # Apply attributes using setter methods (will raise NoMethodError if attribute doesn't exist)
52
- attrs.each do |key, value|
53
- temp_record.public_send("#{key}=", value)
54
- end
98
+ define_singleton_method(:transaction) do |**options, &block|
99
+ # If already in a transaction, just call super without creating a backup
100
+ return super(**options, &block) if connection.transaction_open?
55
101
 
56
- storage.update(
57
- name: record.class.record_name,
58
- id: record.id,
59
- attrs: temp_record.attributes
60
- )
102
+ transaction_mutex.synchronize do
103
+ result = super(**options, &block)
61
104
 
62
- nil
63
- end
105
+ timestamp = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
106
+ backup_path = File.join(me.schema.dir, "versions/#{timestamp}.sqlite3")
107
+ FileUtils.mkdir_p(File.dirname(backup_path))
108
+ FileUtils.cp(me.send(:db_path), backup_path)
64
109
 
65
- def all(record_name)
66
- storage
67
- .all(name: record_name)
68
- .map { schema.build(record_name, attrs: _1) }
69
- end
110
+ result
111
+ end
112
+ end
113
+ end
114
+
115
+ # ActiveRecord doesn't let this be anonymous?
116
+ schema_root_mod.const_set(
117
+ "Base",
118
+ klass
119
+ )
120
+
121
+ klass.abstract_class = true
122
+ klass.establish_connection(
123
+ adapter: 'sqlite3',
124
+ database: db_path
125
+ )
126
+
127
+ # Configure SQLite to use DELETE journal mode instead of WAL
128
+ # This avoids creating -wal and -shm files
129
+ klass.connection.execute("PRAGMA journal_mode=DELETE")
130
+ klass.connection.execute("PRAGMA locking_mode=NORMAL")
131
+
132
+ # Only run migrations for the root store, not for version snapshots
133
+ if root? && schema.migrations.any?
134
+ # Ensure schema_migrations table exists
135
+ unless klass.connection.table_exists?(:schema_migrations)
136
+ klass.connection.create_table(:schema_migrations, id: false) do |t|
137
+ t.string :version, null: false
138
+ end
139
+ klass.connection.add_index(:schema_migrations, :version, unique: true)
140
+ end
70
141
 
71
- def where(record_name, **filters)
72
- records = all(record_name)
142
+ # Run any migrations that haven't been run yet
143
+ schema.migrations.each do |migration|
144
+ version = migration.version.to_s
73
145
 
74
- return records if filters.empty?
146
+ # Check if migration has already been run using quote method for SQL safety
147
+ existing = klass.connection.select_value(
148
+ "SELECT 1 FROM schema_migrations WHERE version = #{klass.connection.quote(version)} LIMIT 1"
149
+ )
150
+ next if existing
75
151
 
76
- records.select do |record|
77
- filters.all? do |key, value|
78
- attr_value = record.public_send(key)
79
- if value.respond_to?(:call)
80
- value.call(attr_value)
81
- else
82
- attr_value == value
152
+ # Run the migration
153
+ klass.connection.instance_exec(&migration.block)
154
+
155
+ # Record that this migration has been run
156
+ klass.connection.execute(
157
+ "INSERT INTO schema_migrations (version) VALUES (#{klass.connection.quote(version)})"
158
+ )
83
159
  end
84
160
  end
85
- end
161
+
162
+ klass
163
+ )
86
164
  end
87
165
 
88
- def versions
89
- storage.versions.map { self.class.new(_1) }
166
+ def class_for(spec)
167
+ @klasses[spec.name] ||= (
168
+ store = self
169
+ Class.new(base_class) do
170
+ self.table_name = spec.table
171
+
172
+ # Execute the block if present, but define a schema method to ignore schema calls
173
+ if spec.block
174
+ # Define a no-op schema method to prevent errors when the block tries to call it
175
+ define_singleton_method(:schema) { |*args, &blk| }
176
+ class_exec(&spec.block)
177
+ # Remove the schema method after execution
178
+ singleton_class.remove_method(:schema) rescue nil
179
+ end
180
+
181
+ # Make all records readonly if this is a version snapshot
182
+ if not store.send(:root?)
183
+ define_method(:readonly?) { true }
184
+ end
185
+ end
186
+ ).tap {
187
+ schema_root_mod.const_set("Record_#{spec.name}", _1)
188
+ }
90
189
  end
91
190
 
92
- def restore
93
- self.class.new(storage.restore)
191
+ def db_path
192
+ @db_path ||= (
193
+ if root?
194
+ File.join(schema.dir, "db.sqlite3")
195
+ else
196
+ File.join(schema.dir, "versions", "#{version}.sqlite3")
197
+ end
198
+ )
94
199
  end
95
200
 
96
- def ensure_writable
97
- unless storage.writeable?
98
- raise "Unwritable storage: Must 'restore' it before you can write to it"
99
- end
201
+ def root?
202
+ version == :root
100
203
  end
101
204
  end
102
205
  end