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.
- checksums.yaml +4 -4
- data/README.md +457 -91
- data/concurrent_pipeline.gemspec +5 -3
- data/lib/concurrent_pipeline/errors.rb +6 -0
- data/lib/concurrent_pipeline/pipeline.rb +5 -9
- data/lib/concurrent_pipeline/pipelines/processors/asynchronous.rb +81 -18
- data/lib/concurrent_pipeline/pipelines/processors/locker.rb +3 -3
- data/lib/concurrent_pipeline/pipelines/processors/result.rb +11 -0
- data/lib/concurrent_pipeline/pipelines/processors/synchronous.rb +74 -5
- data/lib/concurrent_pipeline/pipelines/schema.rb +39 -13
- data/lib/concurrent_pipeline/store.rb +160 -57
- data/lib/concurrent_pipeline/stores/schema.rb +57 -19
- data/lib/concurrent_pipeline/version.rb +1 -1
- data/lib/concurrent_pipeline.rb +1 -1
- metadata +44 -17
- data/lib/concurrent_pipeline/stores/schema/record.rb +0 -47
- data/lib/concurrent_pipeline/stores/storage/yaml/fs.rb +0 -140
- data/lib/concurrent_pipeline/stores/storage/yaml.rb +0 -196
data/concurrent_pipeline.gemspec
CHANGED
|
@@ -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
|
|
@@ -6,22 +6,18 @@ module ConcurrentPipeline
|
|
|
6
6
|
schema = Pipelines::Schema.new
|
|
7
7
|
schema.instance_exec(&block)
|
|
8
8
|
|
|
9
|
-
new(
|
|
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
|
|
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
|
-
|
|
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(
|
|
13
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
69
|
+
# Clean up finished tasks
|
|
70
|
+
active_tasks.reject!(&:finished?)
|
|
37
71
|
|
|
38
|
-
|
|
39
|
-
|
|
72
|
+
# Try to enqueue more work (only if no failure)
|
|
73
|
+
enqueued_any = enqueue_all(semaphore, active_tasks, task)
|
|
40
74
|
|
|
41
|
-
|
|
42
|
-
|
|
75
|
+
# Stop when nothing is being processed AND nothing new was enqueued
|
|
76
|
+
break if active_tasks.empty? && !enqueued_any
|
|
43
77
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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.
|
|
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.
|
|
23
|
+
locks.delete([producer, record.class.name, record.id])
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
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(
|
|
10
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 "
|
|
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
|
|
17
|
+
klass.new(schema:, version: :root)
|
|
15
18
|
end
|
|
16
19
|
|
|
17
|
-
attr_reader :
|
|
18
|
-
def initialize(
|
|
19
|
-
@
|
|
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(&
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
# Return a new root store for the restored state
|
|
67
|
+
Store.new(schema: schema, version: :root)
|
|
32
68
|
end
|
|
33
69
|
|
|
34
|
-
|
|
35
|
-
ensure_writable
|
|
70
|
+
private
|
|
36
71
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
)
|
|
72
|
+
def schema_root_mod_name
|
|
73
|
+
@schema_root_mod_name ||+ "SchemaRoot_#{schema.object_id}_#{object_id}"
|
|
74
|
+
end
|
|
41
75
|
|
|
42
|
-
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
94
|
+
define_singleton_method(:class_name) do |name|
|
|
95
|
+
"#{me.send(:schema_root_mod)}::Record_#{name}"
|
|
96
|
+
end
|
|
50
97
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
id: record.id,
|
|
59
|
-
attrs: temp_record.attributes
|
|
60
|
-
)
|
|
102
|
+
transaction_mutex.synchronize do
|
|
103
|
+
result = super(**options, &block)
|
|
61
104
|
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
161
|
+
|
|
162
|
+
klass
|
|
163
|
+
)
|
|
86
164
|
end
|
|
87
165
|
|
|
88
|
-
def
|
|
89
|
-
|
|
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
|
|
93
|
-
|
|
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
|
|
97
|
-
|
|
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
|