specwrk 0.7.1 → 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: 690e5d97390d650309a664688c09c777308ad8a0e8c52ef853fd7893a23d73f3
4
- data.tar.gz: b9dfe358291cad4ea4e1a40bebc82305c9d98dc05f2620ee7268c2a60990dbc0
3
+ metadata.gz: 2122e3e3925fdf1c12b6d33899c52ba1b8287e46e7518754ae0ca8188ac149ce
4
+ data.tar.gz: 89579242f18a5f17aac60ac52664e1197cf61ef35228d142298b4ad250057a25
5
5
  SHA512:
6
- metadata.gz: d5fe7fa3b2cdcf27b542a7a75a49168e7dba16f6c92982d77fd66a3d60776a12f4d5e500a3e34945d18913e7b3f4493906116bed47266f115ddffed29373995e
7
- data.tar.gz: b64f866c748808b8e7e5ba788c14a33fbd57f9bf5f8589761ec36fc3745c25cea95bdef291802137530a387577a816128a09dac34db6107133204efe51d34fee
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.1"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -21,6 +21,8 @@ require "specwrk/web/endpoints"
21
21
  module Specwrk
22
22
  class Web
23
23
  class App
24
+ REAP_INTERVAL = 330 # HTTP connection timeout + some buffer
25
+
24
26
  class << self
25
27
  def run!
26
28
  Process.setproctitle "specwrk-server"
@@ -72,6 +74,10 @@ module Specwrk
72
74
  end
73
75
  end
74
76
 
77
+ def initialize
78
+ @reaper_thread = Thread.new { reaper } unless ENV["SPECWRK_SRV_SINGLE_RUN"]
79
+ end
80
+
75
81
  def call(env)
76
82
  env[:request] ||= Rack::Request.new(env)
77
83
 
@@ -100,6 +106,26 @@ module Specwrk
100
106
  Endpoints::NotFound
101
107
  end
102
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
103
129
  end
104
130
  end
105
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,40 +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
11
 
15
- def response
16
- return with_response unless run_id # No run_id, no datastore usage in the endpoint
17
-
18
- datastore.with_lock do |db|
19
- @started_at = if db[:started_at]
20
- Time.parse(db[:started_at])
21
- else
22
- db[:started_at] = Time.now
23
- end
24
-
25
- @pending_queue = PendingQueue.new.merge!(db[:pending] || {})
26
- @processing_queue = Queue.new.merge!(db[:processing] || {})
27
- @completed_queue = CompletedQueue.new.merge!(db[:completed] || {})
28
- @workers = db[:workers] ||= {}
29
-
30
- worker[:first_seen_at] ||= Time.now
31
- worker[:last_seen_at] = Time.now
32
-
33
- with_response.tap do
34
- db[:pending] = pending_queue.to_h
35
- db[:processing] = processing_queue.to_h
36
- db[:completed] = completed_queue.to_h
37
- db[:workers] = workers.to_h
38
- end
39
- end
12
+ worker[:first_seen_at] ||= Time.now
13
+ worker[:last_seen_at] = Time.now
40
14
  end
41
15
 
42
- def with_response
16
+ def response
43
17
  not_found
44
18
  end
45
19
 
@@ -48,11 +22,11 @@ module Specwrk
48
22
  attr_reader :request
49
23
 
50
24
  def not_found
51
- [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..."]]
52
26
  end
53
27
 
54
28
  def ok
55
- [200, {"Content-Type" => "text/plain"}, ["OK, 'ol chap"]]
29
+ [200, {"content-type" => "text/plain"}, ["OK, 'ol chap"]]
56
30
  end
57
31
 
58
32
  def payload
@@ -63,8 +37,24 @@ module Specwrk
63
37
  @body ||= request.body.read
64
38
  end
65
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
+
66
56
  def worker
67
- workers[request.get_header("HTTP_X_SPECWRK_ID")] ||= {}
57
+ workers[request.get_header("HTTP_X_SPECWRK_ID")]
68
58
  end
69
59
 
70
60
  def run_id
@@ -72,11 +62,7 @@ module Specwrk
72
62
  end
73
63
 
74
64
  def run_report_file_path
75
- @run_report_file_path ||= File.join(ENV["SPECWRK_OUT"], run_id, "#{started_at.strftime("%Y%m%dT%H%M%S")}-report.json").to_s
76
- end
77
-
78
- def datastore
79
- 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
80
66
  end
81
67
  end
82
68
 
@@ -84,22 +70,27 @@ module Specwrk
84
70
  NotFound = Class.new(Base)
85
71
 
86
72
  class Health < Base
87
- def with_response
73
+ def response
88
74
  [200, {}, []]
89
75
  end
90
76
  end
91
77
 
92
78
  class Heartbeat < Base
93
- def with_response
79
+ def response
94
80
  ok
95
81
  end
96
82
  end
97
83
 
98
84
  class Seed < Base
99
- def with_response
100
- if ENV["SPECWRK_SRV_SINGLE_SEED_PER_RUN"].nil? || pending_queue.length.zero?
101
- examples = payload.map { |hash| [hash[:id], hash] }.to_h
102
- 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
103
94
  end
104
95
 
105
96
  ok
@@ -107,15 +98,16 @@ module Specwrk
107
98
  end
108
99
 
109
100
  class Complete < Base
110
- def with_response
111
- payload.each do |example|
112
- next unless processing_queue.delete(example[:id].to_sym)
113
- 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
114
107
  end
115
108
 
116
109
  if pending_queue.length.zero? && processing_queue.length.zero? && completed_queue.length.positive? && ENV["SPECWRK_OUT"]
117
110
  completed_queue.dump_and_write(run_report_file_path)
118
- FileUtils.ln_sf(run_report_file_path, File.join(ENV["SPECWRK_OUT"], "report.json"))
119
111
  end
120
112
 
121
113
  ok
@@ -123,19 +115,21 @@ module Specwrk
123
115
  end
124
116
 
125
117
  class Pop < Base
126
- def with_response
127
- @examples = pending_queue.shift_bucket
118
+ def response
119
+ processing_queue.synchronize do |processing_queue_hash|
120
+ @examples = pending_queue.shift_bucket
128
121
 
129
- @examples.each do |example|
130
- processing_queue[example[:id]] = example
122
+ @examples.each do |example|
123
+ processing_queue_hash[example[:id]] = example
124
+ end
131
125
  end
132
126
 
133
127
  if @examples.length.positive?
134
- [200, {"Content-Type" => "application/json"}, [JSON.generate(@examples)]]
128
+ [200, {"content-type" => "application/json"}, [JSON.generate(@examples)]]
135
129
  elsif pending_queue.length.zero? && processing_queue.length.zero? && completed_queue.length.zero?
136
- [204, {"Content-Type" => "text/plain"}, ["Waiting for sample to be seeded."]]
130
+ [204, {"content-type" => "text/plain"}, ["Waiting for sample to be seeded."]]
137
131
  elsif completed_queue.length.positive? && processing_queue.length.zero?
138
- [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."]]
139
133
  else
140
134
  not_found
141
135
  end
@@ -143,11 +137,11 @@ module Specwrk
143
137
  end
144
138
 
145
139
  class Report < Base
146
- def with_response
140
+ def response
147
141
  if data
148
- [200, {"Content-Type" => "application/json"}, [data]]
142
+ [200, {"content-type" => "application/json"}, [data]]
149
143
  else
150
- [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"}"]]
151
145
  end
152
146
  end
153
147
 
@@ -166,15 +160,15 @@ module Specwrk
166
160
  end
167
161
 
168
162
  def most_recent_run_report_file
169
- @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
170
164
  end
171
165
  end
172
166
 
173
167
  class Shutdown < Base
174
- def with_response
168
+ def response
175
169
  interupt! if ENV["SPECWRK_SRV_SINGLE_RUN"]
176
170
 
177
- [200, {"Content-Type" => "text/plain"}, ["✌️"]]
171
+ [200, {"content-type" => "text/plain"}, ["✌️"]]
178
172
  end
179
173
 
180
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.1
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