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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c6ab0d31757bbed9bf9f9ba02172a9bdd0b1ba57c7209f9d694b6471588ddd1
4
- data.tar.gz: 44a634acd2023813f35b3eac9bcc331782c4cf624d716bf1d6341db80a07d464
3
+ metadata.gz: 2122e3e3925fdf1c12b6d33899c52ba1b8287e46e7518754ae0ca8188ac149ce
4
+ data.tar.gz: 89579242f18a5f17aac60ac52664e1197cf61ef35228d142298b4ad250057a25
5
5
  SHA512:
6
- metadata.gz: 6c29b9f3e52b7b4d88920b6a4d92cba4b24fed583573b9cfa8dd273777a73534ded97f2a89ecc078d58d183e48a0095a1403900de7c3d497954f6c9002b4e962
7
- data.tar.gz: ce81b65408313ce7088ba157c4578ffae66c15f9f8d4e053992c8eb1c4c99a9ecbc3cb68e1903e6817d988740b7dd57e282866a06a23146841ca057adb89fb19
6
+ metadata.gz: c357e649df3ad4533290c7e53be49d1f4a3bf86870b5cc01ed4bc2219393bc026d4056e65c82b21c9f251724fe3adcebf6efda7a2643b85d873e4ede7efe17d3
7
+ data.tar.gz: 1aea05a1f33cd4bfeddbbe76d7d97c7dd3535ef4d1c24b5116042bcfe37201acc7a70c0839a6ef28ba9a5ecd9380535a7007c8a118e5277fc8d798899d7f28e1
@@ -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 GEMFILE=specwrk-$SPECWRK_VERSION.gem
11
+ ARG GEM_FILE=specwrk-$SPECWRK_VERSION.gem
17
12
 
18
- COPY $GEMFILE ./
19
- RUN gem install ./$GEMFILE --no-document
20
- RUN rm ./$GEMFILE
13
+ COPY $GEM_FILE ./
14
+ RUN gem install ./$GEM_FILE --no-document
15
+ RUN rm ./$GEM_FILE
21
16
 
22
- RUN gem install puma thruster
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
@@ -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:-305}
5
+ export THRUSTER_HTTP_IDLE_TIMEOUT=${IDLE_TIMEOUT:-300}
6
6
 
7
- exec thrust puma --workers 0 --bind tcp://127.0.0.1:3000 --threads ${PUMA_THREADS:-1}
7
+ exec thrust pitchfork -c pitchfork.conf
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ worker_processes 1
4
+ listen "localhost:3000", backlog: 2048
5
+ timeout 301
data/lib/specwrk/queue.rb CHANGED
@@ -4,7 +4,44 @@ require "time"
4
4
  require "json"
5
5
 
6
6
  module Specwrk
7
- Queue = Class.new(Hash)
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
- h2.each { |_id, example| merge_example(example) }
84
+ synchronize do
85
+ h2.each { |_id, example| merge_example(example) }
48
86
 
49
- # Sort by exepcted run time, slowest to fastest
50
- new_h = sort_by { |_, example| example[:expected_run_time] }.reverse.to_h
51
- clear
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
- key = keys.first
71
- return [] if key.nil?
107
+ @mutex.synchronize do
108
+ key = @hash.keys.first
109
+ break if key.nil?
72
110
 
73
- file_path = self[key][:file_path]
74
- each do |id, example|
75
- next unless example[:file_path] == file_path
111
+ file_path = @hash[key][:file_path]
112
+ @hash.each do |id, example|
113
+ next unless example[:file_path] == file_path
76
114
 
77
- bucket << example
78
- delete id
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
- estimated_run_time_total = 0
127
+ @mutex.synchronize do
128
+ estimated_run_time_total = 0
89
129
 
90
- while estimated_run_time_total < run_time_bucket_threshold
91
- key = keys.first
92
- break if key.nil?
130
+ while estimated_run_time_total < run_time_bucket_threshold
131
+ key = @hash.keys.first
132
+ break if key.nil?
93
133
 
94
- estimated_run_time_total += dig(key, :expected_run_time)
95
- break if estimated_run_time_total > run_time_bucket_threshold && bucket.length.positive?
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
- bucket << self[key]
98
- delete key
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
- self[example[:id]] = if previous_run_times
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
- @run_times = []
127
- @first_started_at = Time.new(2999, 1, 1, 0, 0, 0) # TODO: Make future proof /s
128
- @last_finished_at = Time.new(1900, 1, 1, 0, 0, 0)
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
- @output = {
131
- file_totals: Hash.new { |h, filename| h[filename] = 0.0 },
132
- meta: {failures: 0, passes: 0, pending: 0},
133
- examples: {}
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
- values.each { |example| calculate(example) }
179
+ @hash.values.each { |example| calculate(example) }
137
180
 
138
- @output[:meta][:total_run_time] = @run_times.sum
139
- @output[:meta][:average_run_time] = @output[:meta][:total_run_time] / [@run_times.length, 1].max.to_f
140
- @output[:meta][:first_started_at] = @first_started_at.iso8601(6)
141
- @output[:meta][:last_finished_at] = @last_finished_at.iso8601(6)
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
- @output
186
+ @output
187
+ end
144
188
  end
145
189
 
146
190
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specwrk
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -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
@@ -28,7 +28,7 @@ module Specwrk
28
28
  private
29
29
 
30
30
  def unauthorized
31
- [401, {"Content-Type" => "application/json"}, ["Unauthorized"]]
31
+ [401, {"content-type" => "application/json"}, ["Unauthorized"]]
32
32
  end
33
33
  end
34
34
  end
@@ -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
- worker[:first_seen_at] ||= Time.now
29
- worker[:last_seen_at] = Time.now
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 with_response
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, {"Content-Type" => "text/plain"}, ["This is not the path you're looking for, 'ol chap..."]]
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, {"Content-Type" => "text/plain"}, ["OK, 'ol chap"]]
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"], run_id, "#{started_at.strftime("%Y%m%dT%H%M%S")}-report.json").to_s
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 with_response
73
+ def response
86
74
  [200, {}, []]
87
75
  end
88
76
  end
89
77
 
90
78
  class Heartbeat < Base
91
- def with_response
79
+ def response
92
80
  ok
93
81
  end
94
82
  end
95
83
 
96
84
  class Seed < Base
97
- def with_response
98
- if ENV["SPECWRK_SRV_SINGLE_SEED_PER_RUN"].nil? || pending_queue.length.zero?
99
- examples = payload.map { |hash| [hash[:id], hash] }.to_h
100
- pending_queue.merge_with_previous_run_times!(examples)
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 with_response
109
- payload.each do |example|
110
- next unless processing_queue.delete(example[:id].to_sym)
111
- completed_queue[example[:id].to_sym] = example
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 with_response
125
- @examples = pending_queue.shift_bucket
118
+ def response
119
+ processing_queue.synchronize do |processing_queue_hash|
120
+ @examples = pending_queue.shift_bucket
126
121
 
127
- @examples.each do |example|
128
- processing_queue[example[:id]] = example
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, {"Content-Type" => "application/json"}, [JSON.generate(@examples)]]
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, {"Content-Type" => "text/plain"}, ["Waiting for sample to be seeded."]]
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, {"Content-Type" => "text/plain"}, ["That's a good lad. Run along now and go home."]]
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 with_response
140
+ def response
145
141
  if data
146
- [200, {"Content-Type" => "application/json"}, [data]]
142
+ [200, {"content-type" => "application/json"}, [data]]
147
143
  else
148
- [404, {"Content-Type" => "text/plain"}, ["Unable to report on run #{run_id}; no file matching #{"*-report-#{run_id}.json"}"]]
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"], run_id, "*-report.json")).last
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 with_response
168
+ def response
173
169
  interupt! if ENV["SPECWRK_SRV_SINGLE_RUN"]
174
170
 
175
- [200, {"Content-Type" => "text/plain"}, ["✌️"]]
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
- class << self
9
- def datastore
10
- Filestore
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.7.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
@@ -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