combinaut_stagehand 1.2.1

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.
@@ -0,0 +1,198 @@
1
+ module Stagehand
2
+ module Staging
3
+ module Synchronizer
4
+ extend self
5
+ mattr_accessor :schemas_match
6
+
7
+ BATCH_SIZE = 1000
8
+ ENTRY_SYNC_ORDER = [:delete, :update, :insert].freeze
9
+ ENTRY_SYNC_ORDER_SQL = Arel.sql(ActiveRecord::Base.send(:sanitize_sql_for_order, [Arel.sql('FIELD(operation, ?), id ASC'), ENTRY_SYNC_ORDER])).freeze
10
+
11
+ # Immediately sync the changes from the block, preconfirming all changes
12
+ # The block is wrapped in a transaction to prevent changes to records while being synced
13
+ def sync_now!(*args, **opts, &block)
14
+ sync_now(*args, **opts, preconfirmed: true, &block)
15
+ end
16
+
17
+ # Immediately attempt to sync the changes from the block if possible
18
+ # The block is wrapped in a transaction to prevent changes to records while being synced
19
+ def sync_now(subject_record = nil, preconfirmed: false, **opts, &block)
20
+ raise SyncBlockRequired unless block_given?
21
+
22
+ Rails.logger.info "Syncing Now (preconfirmed: #{preconfirmed})"
23
+ Database.transaction do
24
+ commit = Commit.capture(subject_record, &block)
25
+ next unless commit # If the commit was empty don't continue
26
+ checklist = Checklist.new(commit.entries)
27
+ sync_checklist(checklist, **opts) if preconfirmed || !checklist.requires_confirmation?
28
+ end
29
+ end
30
+
31
+ def auto_sync(polling_delay = 5.seconds, **opts)
32
+ loop do
33
+ Rails.logger.info "Autosyncing"
34
+ sync(BATCH_SIZE, **opts)
35
+ sleep(polling_delay) if polling_delay
36
+ rescue Database::NoRetryError => e
37
+ Rails.logger.info "Autosyncing encountered a NoRetryError"
38
+ end
39
+ end
40
+
41
+ def sync(limit = nil, **opts)
42
+ synced_count = 0
43
+ deleted_count = 0
44
+
45
+ Rails.logger.info "Syncing"
46
+
47
+ iterate_autosyncable_entries do |entry|
48
+ sync_entry(entry, :callbacks => :sync, **opts)
49
+ synced_count += 1
50
+
51
+ scope = CommitEntry.matching(entry).not_in_progress
52
+ scope = scope.save_operations unless entry.delete_operation?
53
+ deleted_count += delete_without_range_locks(scope)
54
+
55
+ break if synced_count == limit
56
+ end
57
+
58
+ Rails.logger.info "Synced #{synced_count} entries"
59
+ Rails.logger.info "Removed #{deleted_count} stale entries"
60
+
61
+ return synced_count
62
+ end
63
+
64
+ def sync_all(**opts)
65
+ loop do
66
+ entries = CommitEntry.order(ENTRY_SYNC_ORDER_SQL).limit(BATCH_SIZE).to_a
67
+ break unless entries.present?
68
+
69
+ latest_entries = entries.uniq(&:key)
70
+ latest_entries.each {|entry| sync_entry(entry, :callbacks => :sync, **opts) }
71
+ Rails.logger.info "Synced #{latest_entries.count} entries"
72
+
73
+ deleted_count = delete_without_range_locks(CommitEntry.matching(latest_entries))
74
+ Rails.logger.info "Removed #{deleted_count - latest_entries.count} stale entries"
75
+ end
76
+ end
77
+
78
+ # Copies all the affected records from the staging database to the production database
79
+ def sync_record(record, **opts)
80
+ sync_checklist(Checklist.new(record), **opts)
81
+ end
82
+
83
+ def sync_checklist(checklist, **opts)
84
+ Database.transaction do
85
+ checklist.syncing_entries.each do |entry|
86
+ if checklist.subject_entries.include?(entry)
87
+ sync_entry(entry, :callbacks => [:sync, :sync_as_subject], **opts)
88
+ else
89
+ sync_entry(entry, :callbacks => [:sync, :sync_as_affected], **opts)
90
+ end
91
+ end
92
+
93
+ delete_without_range_locks(checklist.affected_entries)
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ # Lazily iterate through millions of commit entries
100
+ # Returns commit entries in ID descending order
101
+ def iterate_autosyncable_entries(&block)
102
+ current = CommitEntry.maximum(:id).to_i
103
+
104
+ while entries = autosyncable_entries("id <= #{current}").limit(BATCH_SIZE).order(ENTRY_SYNC_ORDER_SQL).to_a.presence do
105
+ with_confirmed_autosyncability(entries.uniq(&:key), &block)
106
+ current = entries.last.try(:id).to_i - 1
107
+ end
108
+ end
109
+
110
+ # Executes the code in the block if the record referred to by the entry is in fact, autosyncable.
111
+ # This confirmation is used to guard against writes to the record that occur after loading an initial list of
112
+ # entries that are autosyncable, but before the record is actually synced. To prevent this, a lock on the record
113
+ # is acquired and then the record's autosync eligibility is rechecked before calling the block.
114
+ def with_confirmed_autosyncability(entries, &block)
115
+ entries = Array.wrap(entries)
116
+ return unless entries.present?
117
+
118
+ Database.transaction do
119
+ # Lock the records so nothing can update them after we confirm autosyncability
120
+ acquire_record_locks(entries)
121
+
122
+ # Execute the block for each entry we've confirm autosyncability
123
+ confirmed_ids = Set.new(autosyncable_entries.where(:id => entries).pluck(:id))
124
+
125
+ entries.each do |entry|
126
+ block.call(entry) if confirmed_ids.include?(entry.id)
127
+ end
128
+ end
129
+ end
130
+
131
+ # Does not actually acquire a lock, instead it triggers a 'first read' so the transaction will ensure subsequent
132
+ # reads of the locked rows return the same value, even if modified outside of the transaction
133
+ def acquire_record_locks(entries)
134
+ entries.each(&:record)
135
+ end
136
+
137
+ def autosyncable_entries(scope = nil)
138
+ entries = CommitEntry.content_operations.where(scope)
139
+ if Configuration.ghost_mode?
140
+ entries = entries.not_in_progress
141
+ else
142
+ entries = entries.with_uncontained_keys
143
+ end
144
+ return entries
145
+ end
146
+
147
+ def sync_entry(entry, callbacks: false)
148
+ raise SchemaMismatch unless schemas_match?
149
+
150
+ run_sync_callbacks(entry, callbacks) do
151
+ next unless entry.content_operation? # Only sync records from content operations because those are the only rows that have changes
152
+ next if Configuration.single_connection? # Avoid deadlocking if the databases are the same. There is nothing to sync because there is only a single database
153
+
154
+ Rails.logger.info "Synchronizing #{entry.table_name} #{entry.record_id} (#{entry.operation})"
155
+
156
+ if entry.delete_operation?
157
+ Production.delete(entry)
158
+ elsif entry.save_operation?
159
+ Production.save(entry)
160
+ end
161
+
162
+ Rails.logger.info "Synchronized #{entry.table_name} #{entry.record_id} (#{entry.operation})"
163
+ end
164
+ end
165
+
166
+ def schemas_match?
167
+ return schemas_match unless schemas_match.nil?
168
+ self.schemas_match = Database.staging_database_versions == Database.production_database_versions
169
+ return schemas_match
170
+ end
171
+
172
+ def run_sync_callbacks(entry, callbacks, &block)
173
+ callbacks = Array.wrap(callbacks.presence).dup
174
+ return block.call unless callbacks.present? && entry.record
175
+
176
+ entry.record.run_callbacks(callbacks.shift) do
177
+ run_sync_callbacks(entry, callbacks, &block)
178
+ end
179
+ end
180
+
181
+ # Deletes records without acquiring range locks which have a higher likelihood of causing a deadlock.
182
+ # See https://dev.mysql.com/doc/refman/5.6/en/innodb-locks-set.html for info on locks set by SQL statements.
183
+ def delete_without_range_locks(commit_entries)
184
+ ids = commit_entries.pluck(:id)
185
+ ids.in_groups_of(1000, false) do |batch|
186
+ CommitEntry.delete(batch)
187
+ end
188
+
189
+ return ids.length
190
+ end
191
+ end
192
+ end
193
+
194
+ # EXCEPTIONS
195
+
196
+ class SyncBlockRequired < StandardError; end
197
+ class SchemaMismatch < StandardError; end
198
+ end
@@ -0,0 +1,11 @@
1
+ require 'stagehand/staging/commit'
2
+ require 'stagehand/staging/commit_entry'
3
+ require 'stagehand/staging/checklist'
4
+ require 'stagehand/staging/controller'
5
+ require 'stagehand/staging/model'
6
+ require 'stagehand/staging/synchronizer'
7
+
8
+ module Stagehand
9
+ module Staging
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module Stagehand
2
+ VERSION = "1.2.1"
3
+ end
data/lib/stagehand.rb ADDED
@@ -0,0 +1,5 @@
1
+ require 'rails/all'
2
+ require 'stagehand/engine'
3
+
4
+ module Stagehand
5
+ end
@@ -0,0 +1,43 @@
1
+ namespace :stagehand do
2
+ desc "Polls the commit entries table for changes to sync to production"
3
+ task :auto_sync, [:delay] => :environment do |t, args|
4
+ delay = args[:delay].present? ? args[:delay].to_i : 5.seconds
5
+ Stagehand::Staging::Synchronizer.auto_sync(delay)
6
+ end
7
+
8
+ desc "Syncs records that don't need confirmation to production"
9
+ task :sync, [:limit] => :environment do |t, args|
10
+ limit = args[:limit].present? ? args[:limit].to_i : nil
11
+ Stagehand::Staging::Synchronizer.sync(limit)
12
+ end
13
+
14
+ desc "Syncs all records to production, including those that require confirmation"
15
+ task :sync_all => :environment do
16
+ Stagehand::Staging::Synchronizer.sync_all
17
+ end
18
+
19
+ # Enhance the regular tasks to run on both staging and production databases
20
+ def rake_both_databases(task, stagehand_task = task.gsub(':','_'))
21
+ task(stagehand_task => :environment) do
22
+ Stagehand::Database.each do |connection_name|
23
+ Stagehand::Connection.with_production_writes do
24
+ puts "#{connection_name}"
25
+ Rake::Task[task].reenable
26
+ Rake::Task[task].invoke
27
+ end
28
+ end
29
+ Rake::Task[task].clear
30
+ end
31
+
32
+ # Enhance the original task to run the stagehand_task as a prerequisite
33
+ Rake::Task[task].enhance(["stagehand:#{stagehand_task}"]) unless Stagehand::Configuration.single_connection?
34
+ end
35
+
36
+ rake_both_databases('db:create')
37
+ rake_both_databases('db:migrate')
38
+ rake_both_databases('db:migrate:up')
39
+ rake_both_databases('db:migrate:down')
40
+ rake_both_databases('db:rollback')
41
+ rake_both_databases('db:test:load_structure')
42
+ rake_both_databases('db:test:load_schema')
43
+ end
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: combinaut_stagehand
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Nicholas Jakobsen
8
+ - Ryan Wallace
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2025-11-07 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rails
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '4.2'
21
+ - - "<"
22
+ - !ruby/object:Gem::Version
23
+ version: '6'
24
+ type: :runtime
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ requirements:
28
+ - - ">="
29
+ - !ruby/object:Gem::Version
30
+ version: '4.2'
31
+ - - "<"
32
+ - !ruby/object:Gem::Version
33
+ version: '6'
34
+ - !ruby/object:Gem::Dependency
35
+ name: mysql2
36
+ requirement: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ type: :runtime
42
+ prerelease: false
43
+ version_requirements: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ - !ruby/object:Gem::Dependency
49
+ name: ruby-graphviz
50
+ requirement: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: combustion
64
+ requirement: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.3.0
69
+ type: :development
70
+ prerelease: false
71
+ version_requirements: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 1.3.0
76
+ - !ruby/object:Gem::Dependency
77
+ name: rspec-rails
78
+ requirement: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.7'
83
+ type: :development
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.7'
90
+ - !ruby/object:Gem::Dependency
91
+ name: database_cleaner
92
+ requirement: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ type: :development
98
+ prerelease: false
99
+ version_requirements: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ description: Simplify the management of a sandbox database that can sync content to
105
+ a production database. Content changes can be bundled to allow partial syncs of
106
+ the database.
107
+ email:
108
+ - nicholas@combinaut.com
109
+ - ryan@combinaut.com
110
+ executables: []
111
+ extensions: []
112
+ extra_rdoc_files: []
113
+ files:
114
+ - MIT-LICENSE
115
+ - Rakefile
116
+ - lib/combinaut_stagehand.rb
117
+ - lib/stagehand.rb
118
+ - lib/stagehand/active_record_extensions.rb
119
+ - lib/stagehand/auditor.rb
120
+ - lib/stagehand/auditor/checklist_visualizer.rb
121
+ - lib/stagehand/cache.rb
122
+ - lib/stagehand/configuration.rb
123
+ - lib/stagehand/connection_adapter_extensions.rb
124
+ - lib/stagehand/controller_extensions.rb
125
+ - lib/stagehand/database.rb
126
+ - lib/stagehand/engine.rb
127
+ - lib/stagehand/key.rb
128
+ - lib/stagehand/production.rb
129
+ - lib/stagehand/production/controller.rb
130
+ - lib/stagehand/schema.rb
131
+ - lib/stagehand/schema/statements.rb
132
+ - lib/stagehand/schema_extensions.rb
133
+ - lib/stagehand/staging.rb
134
+ - lib/stagehand/staging/checklist.rb
135
+ - lib/stagehand/staging/commit.rb
136
+ - lib/stagehand/staging/commit_entry.rb
137
+ - lib/stagehand/staging/controller.rb
138
+ - lib/stagehand/staging/model.rb
139
+ - lib/stagehand/staging/synchronizer.rb
140
+ - lib/stagehand/version.rb
141
+ - lib/tasks/stagehand_tasks.rake
142
+ homepage: https://github.com/combinaut/stagehand
143
+ licenses:
144
+ - MIT
145
+ metadata: {}
146
+ post_install_message:
147
+ rdoc_options: []
148
+ require_paths:
149
+ - lib
150
+ required_ruby_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ required_rubygems_version: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ requirements: []
161
+ rubygems_version: 3.1.6
162
+ signing_key:
163
+ specification_version: 4
164
+ summary: Simplify the management of a sandbox database that can sync content to a
165
+ production database
166
+ test_files: []