specwrk 0.7.0 → 0.8.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/docker/Dockerfile.server +7 -11
- data/docker/entrypoint.server.sh +2 -2
- data/docker/pitchfork.conf +5 -0
- data/lib/specwrk/queue.rb +82 -38
- data/lib/specwrk/version.rb +1 -1
- data/lib/specwrk/web/app.rb +24 -0
- data/lib/specwrk/web/auth.rb +1 -1
- data/lib/specwrk/web/endpoints.rb +55 -59
- data/lib/specwrk/web.rb +12 -4
- metadata +2 -2
- data/lib/specwrk/filestore.rb +0 -64
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2122e3e3925fdf1c12b6d33899c52ba1b8287e46e7518754ae0ca8188ac149ce
|
4
|
+
data.tar.gz: 89579242f18a5f17aac60ac52664e1197cf61ef35228d142298b4ad250057a25
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c357e649df3ad4533290c7e53be49d1f4a3bf86870b5cc01ed4bc2219393bc026d4056e65c82b21c9f251724fe3adcebf6efda7a2643b85d873e4ede7efe17d3
|
7
|
+
data.tar.gz: 1aea05a1f33cd4bfeddbbe76d7d97c7dd3535ef4d1c24b5116042bcfe37201acc7a70c0839a6ef28ba9a5ecd9380535a7007c8a118e5277fc8d798899d7f28e1
|
data/docker/Dockerfile.server
CHANGED
@@ -1,11 +1,6 @@
|
|
1
1
|
FROM ruby:3.4-alpine
|
2
2
|
|
3
|
-
RUN apk add --no-cache
|
4
|
-
build-base \
|
5
|
-
ruby-dev \
|
6
|
-
linux-headers \
|
7
|
-
zlib-dev \
|
8
|
-
libffi-dev
|
3
|
+
RUN apk add --no-cache build-base
|
9
4
|
|
10
5
|
WORKDIR /app
|
11
6
|
|
@@ -13,14 +8,15 @@ RUN mkdir .specwrk/
|
|
13
8
|
|
14
9
|
ARG SPECWRK_SRV_PORT=5138
|
15
10
|
ARG SPECWRK_VERSION=latest
|
16
|
-
ARG
|
11
|
+
ARG GEM_FILE=specwrk-$SPECWRK_VERSION.gem
|
17
12
|
|
18
|
-
COPY $
|
19
|
-
RUN gem install ./$
|
20
|
-
RUN rm ./$
|
13
|
+
COPY $GEM_FILE ./
|
14
|
+
RUN gem install ./$GEM_FILE --no-document
|
15
|
+
RUN rm ./$GEM_FILE
|
21
16
|
|
22
|
-
RUN gem install
|
17
|
+
RUN gem install pitchfork thruster
|
23
18
|
COPY config.ru ./
|
19
|
+
COPY docker/pitchfork.conf ./
|
24
20
|
|
25
21
|
COPY docker/entrypoint.server.sh /usr/local/bin/entrypoint
|
26
22
|
RUN chmod +x /usr/local/bin/entrypoint
|
data/docker/entrypoint.server.sh
CHANGED
@@ -2,6 +2,6 @@
|
|
2
2
|
|
3
3
|
export THRUSTER_HTTP_PORT=${PORT:-5138}
|
4
4
|
export THRUSTER_TARGET_PORT=3000
|
5
|
-
export THRUSTER_HTTP_IDLE_TIMEOUT=${IDLE_TIMEOUT:-
|
5
|
+
export THRUSTER_HTTP_IDLE_TIMEOUT=${IDLE_TIMEOUT:-300}
|
6
6
|
|
7
|
-
exec thrust
|
7
|
+
exec thrust pitchfork -c pitchfork.conf
|
data/lib/specwrk/queue.rb
CHANGED
@@ -4,7 +4,44 @@ require "time"
|
|
4
4
|
require "json"
|
5
5
|
|
6
6
|
module Specwrk
|
7
|
-
|
7
|
+
# Thread-safe Hash access
|
8
|
+
class Queue
|
9
|
+
attr_reader :created_at
|
10
|
+
|
11
|
+
def initialize(hash = {})
|
12
|
+
@created_at = Time.now
|
13
|
+
|
14
|
+
if block_given?
|
15
|
+
@mutex = Monitor.new # Reentrant locking is required here
|
16
|
+
# It's possible to enter the proc from two threads, so we need to ||= in case
|
17
|
+
# one thread has set a value prior to the yield.
|
18
|
+
hash.default_proc = proc { |h, key| @mutex.synchronize { yield(h, key) } }
|
19
|
+
end
|
20
|
+
|
21
|
+
@mutex ||= Mutex.new # Monitor is up-to 20% slower than Mutex, so if no block is given, use a mutex
|
22
|
+
@hash = hash
|
23
|
+
end
|
24
|
+
|
25
|
+
def synchronize(&blk)
|
26
|
+
if @mutex.owned?
|
27
|
+
yield(@hash)
|
28
|
+
else
|
29
|
+
@mutex.synchronize { yield(@hash) }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def method_missing(name, *args, &block)
|
34
|
+
if @hash.respond_to?(name)
|
35
|
+
@mutex.synchronize { @hash.public_send(name, *args, &block) }
|
36
|
+
else
|
37
|
+
super
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def respond_to_missing?(name, include_private = false)
|
42
|
+
@hash.respond_to?(name, include_private) || super
|
43
|
+
end
|
44
|
+
end
|
8
45
|
|
9
46
|
class PendingQueue < Queue
|
10
47
|
def shift_bucket
|
@@ -44,12 +81,12 @@ module Specwrk
|
|
44
81
|
end
|
45
82
|
|
46
83
|
def merge_with_previous_run_times!(h2)
|
47
|
-
|
84
|
+
synchronize do
|
85
|
+
h2.each { |_id, example| merge_example(example) }
|
48
86
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
merge!(new_h)
|
87
|
+
# Sort by exepcted run time, slowest to fastest
|
88
|
+
@hash = @hash.sort_by { |_, example| example[:expected_run_time] }.reverse.to_h
|
89
|
+
end
|
53
90
|
end
|
54
91
|
|
55
92
|
private
|
@@ -67,15 +104,17 @@ module Specwrk
|
|
67
104
|
def bucket_by_file
|
68
105
|
bucket = []
|
69
106
|
|
70
|
-
|
71
|
-
|
107
|
+
@mutex.synchronize do
|
108
|
+
key = @hash.keys.first
|
109
|
+
break if key.nil?
|
72
110
|
|
73
|
-
|
74
|
-
|
75
|
-
|
111
|
+
file_path = @hash[key][:file_path]
|
112
|
+
@hash.each do |id, example|
|
113
|
+
next unless example[:file_path] == file_path
|
76
114
|
|
77
|
-
|
78
|
-
|
115
|
+
bucket << example
|
116
|
+
@hash.delete id
|
117
|
+
end
|
79
118
|
end
|
80
119
|
|
81
120
|
bucket
|
@@ -85,27 +124,30 @@ module Specwrk
|
|
85
124
|
def bucket_by_timings
|
86
125
|
bucket = []
|
87
126
|
|
88
|
-
|
127
|
+
@mutex.synchronize do
|
128
|
+
estimated_run_time_total = 0
|
89
129
|
|
90
|
-
|
91
|
-
|
92
|
-
|
130
|
+
while estimated_run_time_total < run_time_bucket_threshold
|
131
|
+
key = @hash.keys.first
|
132
|
+
break if key.nil?
|
93
133
|
|
94
|
-
|
95
|
-
|
134
|
+
estimated_run_time_total += @hash.dig(key, :expected_run_time)
|
135
|
+
break if estimated_run_time_total > run_time_bucket_threshold && bucket.length.positive?
|
96
136
|
|
97
|
-
|
98
|
-
|
137
|
+
bucket << @hash[key]
|
138
|
+
@hash.delete key
|
139
|
+
end
|
99
140
|
end
|
100
141
|
|
101
142
|
bucket
|
102
143
|
end
|
103
144
|
|
145
|
+
# Ensure @mutex is held when calling this method
|
104
146
|
def merge_example(example)
|
105
|
-
return if key? example[:id]
|
106
|
-
return if key? example[:file_path]
|
147
|
+
return if @hash.key? example[:id]
|
148
|
+
return if @hash.key? example[:file_path]
|
107
149
|
|
108
|
-
|
150
|
+
@hash[example[:id]] = if previous_run_times
|
109
151
|
example.merge!(
|
110
152
|
expected_run_time: previous_run_times.dig(:examples, example[:id].to_sym, :run_time) || 99999.9 # run "unknown" files first
|
111
153
|
)
|
@@ -123,24 +165,26 @@ module Specwrk
|
|
123
165
|
end
|
124
166
|
|
125
167
|
def dump
|
126
|
-
@
|
127
|
-
|
128
|
-
|
168
|
+
@mutex.synchronize do
|
169
|
+
@run_times = []
|
170
|
+
@first_started_at = Time.new(2999, 1, 1, 0, 0, 0) # TODO: Make future proof /s
|
171
|
+
@last_finished_at = Time.new(1900, 1, 1, 0, 0, 0)
|
129
172
|
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
173
|
+
@output = {
|
174
|
+
file_totals: Hash.new { |h, filename| h[filename] = 0.0 },
|
175
|
+
meta: {failures: 0, passes: 0, pending: 0},
|
176
|
+
examples: {}
|
177
|
+
}
|
135
178
|
|
136
|
-
|
179
|
+
@hash.values.each { |example| calculate(example) }
|
137
180
|
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
181
|
+
@output[:meta][:total_run_time] = @run_times.sum
|
182
|
+
@output[:meta][:average_run_time] = @output[:meta][:total_run_time] / [@run_times.length, 1].max.to_f
|
183
|
+
@output[:meta][:first_started_at] = @first_started_at.iso8601(6)
|
184
|
+
@output[:meta][:last_finished_at] = @last_finished_at.iso8601(6)
|
142
185
|
|
143
|
-
|
186
|
+
@output
|
187
|
+
end
|
144
188
|
end
|
145
189
|
|
146
190
|
private
|
data/lib/specwrk/version.rb
CHANGED
data/lib/specwrk/web/app.rb
CHANGED
@@ -74,6 +74,10 @@ module Specwrk
|
|
74
74
|
end
|
75
75
|
end
|
76
76
|
|
77
|
+
def initialize
|
78
|
+
@reaper_thread = Thread.new { reaper } unless ENV["SPECWRK_SRV_SINGLE_RUN"]
|
79
|
+
end
|
80
|
+
|
77
81
|
def call(env)
|
78
82
|
env[:request] ||= Rack::Request.new(env)
|
79
83
|
|
@@ -102,6 +106,26 @@ module Specwrk
|
|
102
106
|
Endpoints::NotFound
|
103
107
|
end
|
104
108
|
end
|
109
|
+
|
110
|
+
def reaper
|
111
|
+
until Specwrk.force_quit
|
112
|
+
sleep REAP_INTERVAL
|
113
|
+
|
114
|
+
reap
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def reap
|
119
|
+
Web::WORKERS.each do |run, workers|
|
120
|
+
most_recent_last_seen_at = workers.map { |id, worker| worker[:last_seen_at] }.max
|
121
|
+
next unless most_recent_last_seen_at
|
122
|
+
|
123
|
+
# Don't consider runs which aren't at least REAP_INTERVAL sec stale
|
124
|
+
if most_recent_last_seen_at < Time.now - REAP_INTERVAL
|
125
|
+
Web.clear_run_queues(run)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
105
129
|
end
|
106
130
|
end
|
107
131
|
end
|
data/lib/specwrk/web/auth.rb
CHANGED
@@ -6,38 +6,14 @@ module Specwrk
|
|
6
6
|
class Web
|
7
7
|
module Endpoints
|
8
8
|
class Base
|
9
|
-
attr_reader :pending_queue, :processing_queue, :completed_queue, :workers, :started_at
|
10
|
-
|
11
9
|
def initialize(request)
|
12
10
|
@request = request
|
13
|
-
end
|
14
|
-
|
15
|
-
def response
|
16
|
-
datastore.with_lock do |db|
|
17
|
-
@started_at = if db[:started_at]
|
18
|
-
Time.parse(db[:started_at])
|
19
|
-
else
|
20
|
-
db[:started_at] = Time.now
|
21
|
-
end
|
22
|
-
|
23
|
-
@pending_queue = PendingQueue.new.merge!(db[:pending] || {})
|
24
|
-
@processing_queue = Queue.new.merge!(db[:processing] || {})
|
25
|
-
@completed_queue = CompletedQueue.new.merge!(db[:completed] || {})
|
26
|
-
@workers = db[:workers] ||= {}
|
27
11
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
with_response.tap do
|
32
|
-
db[:pending] = pending_queue.to_h
|
33
|
-
db[:processing] = processing_queue.to_h
|
34
|
-
db[:completed] = completed_queue.to_h
|
35
|
-
db[:workers] = workers.to_h
|
36
|
-
end
|
37
|
-
end
|
12
|
+
worker[:first_seen_at] ||= Time.now
|
13
|
+
worker[:last_seen_at] = Time.now
|
38
14
|
end
|
39
15
|
|
40
|
-
def
|
16
|
+
def response
|
41
17
|
not_found
|
42
18
|
end
|
43
19
|
|
@@ -46,11 +22,11 @@ module Specwrk
|
|
46
22
|
attr_reader :request
|
47
23
|
|
48
24
|
def not_found
|
49
|
-
[404, {"
|
25
|
+
[404, {"content-type" => "text/plain"}, ["This is not the path you're looking for, 'ol chap..."]]
|
50
26
|
end
|
51
27
|
|
52
28
|
def ok
|
53
|
-
[200, {"
|
29
|
+
[200, {"content-type" => "text/plain"}, ["OK, 'ol chap"]]
|
54
30
|
end
|
55
31
|
|
56
32
|
def payload
|
@@ -61,8 +37,24 @@ module Specwrk
|
|
61
37
|
@body ||= request.body.read
|
62
38
|
end
|
63
39
|
|
40
|
+
def pending_queue
|
41
|
+
Web::PENDING_QUEUES[run_id]
|
42
|
+
end
|
43
|
+
|
44
|
+
def processing_queue
|
45
|
+
Web::PROCESSING_QUEUES[request.get_header("HTTP_X_SPECWRK_RUN")]
|
46
|
+
end
|
47
|
+
|
48
|
+
def completed_queue
|
49
|
+
Web::COMPLETED_QUEUES[request.get_header("HTTP_X_SPECWRK_RUN")]
|
50
|
+
end
|
51
|
+
|
52
|
+
def workers
|
53
|
+
Web::WORKERS[request.get_header("HTTP_X_SPECWRK_RUN")]
|
54
|
+
end
|
55
|
+
|
64
56
|
def worker
|
65
|
-
workers[request.get_header("HTTP_X_SPECWRK_ID")]
|
57
|
+
workers[request.get_header("HTTP_X_SPECWRK_ID")]
|
66
58
|
end
|
67
59
|
|
68
60
|
def run_id
|
@@ -70,11 +62,7 @@ module Specwrk
|
|
70
62
|
end
|
71
63
|
|
72
64
|
def run_report_file_path
|
73
|
-
@run_report_file_path ||= File.join(ENV["SPECWRK_OUT"],
|
74
|
-
end
|
75
|
-
|
76
|
-
def datastore
|
77
|
-
Web.datastore[File.join(ENV["SPECWRK_OUT"], run_id, "queues.json").to_s]
|
65
|
+
@run_report_file_path ||= File.join(ENV["SPECWRK_OUT"], "#{completed_queue.created_at.strftime("%Y%m%dT%H%M%S")}-report-#{run_id}.json").to_s
|
78
66
|
end
|
79
67
|
end
|
80
68
|
|
@@ -82,22 +70,27 @@ module Specwrk
|
|
82
70
|
NotFound = Class.new(Base)
|
83
71
|
|
84
72
|
class Health < Base
|
85
|
-
def
|
73
|
+
def response
|
86
74
|
[200, {}, []]
|
87
75
|
end
|
88
76
|
end
|
89
77
|
|
90
78
|
class Heartbeat < Base
|
91
|
-
def
|
79
|
+
def response
|
92
80
|
ok
|
93
81
|
end
|
94
82
|
end
|
95
83
|
|
96
84
|
class Seed < Base
|
97
|
-
def
|
98
|
-
|
99
|
-
|
100
|
-
|
85
|
+
def response
|
86
|
+
pending_queue.synchronize do |pending_queue_hash|
|
87
|
+
unless ENV["SPECWRK_SRV_SINGLE_SEED_PER_RUN"] && pending_queue_hash.length.positive?
|
88
|
+
examples = payload.map { |hash| [hash[:id], hash] }.to_h
|
89
|
+
|
90
|
+
pending_queue.merge_with_previous_run_times!(examples)
|
91
|
+
|
92
|
+
ok
|
93
|
+
end
|
101
94
|
end
|
102
95
|
|
103
96
|
ok
|
@@ -105,15 +98,16 @@ module Specwrk
|
|
105
98
|
end
|
106
99
|
|
107
100
|
class Complete < Base
|
108
|
-
def
|
109
|
-
|
110
|
-
|
111
|
-
|
101
|
+
def response
|
102
|
+
processing_queue.synchronize do |processing_queue_hash|
|
103
|
+
payload.each do |example|
|
104
|
+
next unless processing_queue_hash.delete(example[:id])
|
105
|
+
completed_queue[example[:id]] = example
|
106
|
+
end
|
112
107
|
end
|
113
108
|
|
114
109
|
if pending_queue.length.zero? && processing_queue.length.zero? && completed_queue.length.positive? && ENV["SPECWRK_OUT"]
|
115
110
|
completed_queue.dump_and_write(run_report_file_path)
|
116
|
-
FileUtils.ln_sf(run_report_file_path, File.join(ENV["SPECWRK_OUT"], "report.json"))
|
117
111
|
end
|
118
112
|
|
119
113
|
ok
|
@@ -121,19 +115,21 @@ module Specwrk
|
|
121
115
|
end
|
122
116
|
|
123
117
|
class Pop < Base
|
124
|
-
def
|
125
|
-
|
118
|
+
def response
|
119
|
+
processing_queue.synchronize do |processing_queue_hash|
|
120
|
+
@examples = pending_queue.shift_bucket
|
126
121
|
|
127
|
-
|
128
|
-
|
122
|
+
@examples.each do |example|
|
123
|
+
processing_queue_hash[example[:id]] = example
|
124
|
+
end
|
129
125
|
end
|
130
126
|
|
131
127
|
if @examples.length.positive?
|
132
|
-
[200, {"
|
128
|
+
[200, {"content-type" => "application/json"}, [JSON.generate(@examples)]]
|
133
129
|
elsif pending_queue.length.zero? && processing_queue.length.zero? && completed_queue.length.zero?
|
134
|
-
[204, {"
|
130
|
+
[204, {"content-type" => "text/plain"}, ["Waiting for sample to be seeded."]]
|
135
131
|
elsif completed_queue.length.positive? && processing_queue.length.zero?
|
136
|
-
[410, {"
|
132
|
+
[410, {"content-type" => "text/plain"}, ["That's a good lad. Run along now and go home."]]
|
137
133
|
else
|
138
134
|
not_found
|
139
135
|
end
|
@@ -141,11 +137,11 @@ module Specwrk
|
|
141
137
|
end
|
142
138
|
|
143
139
|
class Report < Base
|
144
|
-
def
|
140
|
+
def response
|
145
141
|
if data
|
146
|
-
[200, {"
|
142
|
+
[200, {"content-type" => "application/json"}, [data]]
|
147
143
|
else
|
148
|
-
[404, {"
|
144
|
+
[404, {"content-type" => "text/plain"}, ["Unable to report on run #{run_id}; no file matching #{"*-report-#{run_id}.json"}"]]
|
149
145
|
end
|
150
146
|
end
|
151
147
|
|
@@ -164,15 +160,15 @@ module Specwrk
|
|
164
160
|
end
|
165
161
|
|
166
162
|
def most_recent_run_report_file
|
167
|
-
@most_recent_run_report_file ||= Dir.glob(File.join(ENV["SPECWRK_OUT"],
|
163
|
+
@most_recent_run_report_file ||= Dir.glob(File.join(ENV["SPECWRK_OUT"], "*-report-#{run_id}.json")).last
|
168
164
|
end
|
169
165
|
end
|
170
166
|
|
171
167
|
class Shutdown < Base
|
172
|
-
def
|
168
|
+
def response
|
173
169
|
interupt! if ENV["SPECWRK_SRV_SINGLE_RUN"]
|
174
170
|
|
175
|
-
[200, {"
|
171
|
+
[200, {"content-type" => "text/plain"}, ["✌️"]]
|
176
172
|
end
|
177
173
|
|
178
174
|
def interupt!
|
data/lib/specwrk/web.rb
CHANGED
@@ -1,13 +1,21 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "specwrk/queue"
|
4
|
-
require "specwrk/filestore"
|
5
4
|
|
6
5
|
module Specwrk
|
7
6
|
class Web
|
8
|
-
|
9
|
-
|
10
|
-
|
7
|
+
PENDING_QUEUES = Queue.new { |h, key| h[key] = PendingQueue.new }
|
8
|
+
PROCESSING_QUEUES = Queue.new { |h, key| h[key] = Queue.new }
|
9
|
+
COMPLETED_QUEUES = Queue.new { |h, key| h[key] = CompletedQueue.new }
|
10
|
+
WORKERS = Hash.new { |h, key| h[key] = Hash.new { |h, key| h[key] = {} } }
|
11
|
+
|
12
|
+
def self.clear_queues
|
13
|
+
[PENDING_QUEUES, PROCESSING_QUEUES, COMPLETED_QUEUES, WORKERS].each(&:clear)
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.clear_run_queues(run)
|
17
|
+
[PENDING_QUEUES, PROCESSING_QUEUES, COMPLETED_QUEUES, WORKERS].each do |queue|
|
18
|
+
queue.delete(run)
|
11
19
|
end
|
12
20
|
end
|
13
21
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: specwrk
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Daniel Westendorf
|
@@ -152,6 +152,7 @@ files:
|
|
152
152
|
- config.ru
|
153
153
|
- docker/Dockerfile.server
|
154
154
|
- docker/entrypoint.server.sh
|
155
|
+
- docker/pitchfork.conf
|
155
156
|
- examples/circleci/config.yml
|
156
157
|
- examples/github/specwrk-multi-node.yml
|
157
158
|
- examples/github/specwrk-single-node.yml
|
@@ -160,7 +161,6 @@ files:
|
|
160
161
|
- lib/specwrk/cli.rb
|
161
162
|
- lib/specwrk/cli_reporter.rb
|
162
163
|
- lib/specwrk/client.rb
|
163
|
-
- lib/specwrk/filestore.rb
|
164
164
|
- lib/specwrk/hookable.rb
|
165
165
|
- lib/specwrk/list_examples.rb
|
166
166
|
- lib/specwrk/queue.rb
|
data/lib/specwrk/filestore.rb
DELETED
@@ -1,64 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "fileutils"
|
4
|
-
|
5
|
-
module Specwrk
|
6
|
-
class Filestore
|
7
|
-
@mutexes = {}
|
8
|
-
@mutexes_mutex = Mutex.new # 🐢🐢🐢🐢
|
9
|
-
|
10
|
-
class << self
|
11
|
-
def [](path)
|
12
|
-
new(path)
|
13
|
-
end
|
14
|
-
|
15
|
-
def mutex_for(path)
|
16
|
-
@mutexes_mutex.synchronize do
|
17
|
-
@mutexes[path] ||= Mutex.new
|
18
|
-
end
|
19
|
-
end
|
20
|
-
end
|
21
|
-
|
22
|
-
def initialize(path)
|
23
|
-
@path = path
|
24
|
-
@tmpfile_path = @path + ".tmp"
|
25
|
-
@lock_path = @path + ".lock"
|
26
|
-
|
27
|
-
FileUtils.mkdir_p File.dirname(@path)
|
28
|
-
File.open(@path, "a") {} # multi-process and multi-thread safe touch
|
29
|
-
end
|
30
|
-
|
31
|
-
def with_lock
|
32
|
-
self.class.mutex_for(@path).synchronize do
|
33
|
-
lock_file.flock(File::LOCK_EX)
|
34
|
-
|
35
|
-
hash = read
|
36
|
-
result = yield(hash)
|
37
|
-
|
38
|
-
# Will truncate if already exists
|
39
|
-
File.open(@tmpfile_path, "w") do |tmpfile|
|
40
|
-
tmpfile.write(hash.to_json)
|
41
|
-
tmpfile.fsync
|
42
|
-
tmpfile.close
|
43
|
-
end
|
44
|
-
|
45
|
-
File.rename(@tmpfile_path, @path)
|
46
|
-
result
|
47
|
-
ensure
|
48
|
-
lock_file.flock(File::LOCK_UN)
|
49
|
-
end
|
50
|
-
end
|
51
|
-
|
52
|
-
private
|
53
|
-
|
54
|
-
def lock_file
|
55
|
-
@lock_file ||= File.open(@lock_path, "w")
|
56
|
-
end
|
57
|
-
|
58
|
-
def read
|
59
|
-
JSON.parse(File.read(@path), symbolize_names: true)
|
60
|
-
rescue JSON::ParserError
|
61
|
-
{}
|
62
|
-
end
|
63
|
-
end
|
64
|
-
end
|