heroku_hatchet 8.0.0 → 8.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/bin/hatchet +5 -1
- data/lib/hatchet/app.rb +20 -3
- data/lib/hatchet/reaper.rb +105 -31
- data/lib/hatchet/version.rb +1 -1
- data/spec/hatchet/app_spec.rb +1 -1
- data/spec/unit/reaper_spec.rb +7 -6
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f4233a137df7685f1e411471db8c53a72c9c6e77e5b9a901d0d8bb1c0147f06a
|
4
|
+
data.tar.gz: ffd93abf69b7a46ccbdda5148710e237b9c41171abef209ea56f187d7a52c501
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 61cc153424acbdf88c61b41ef9b4206fbb1e59c171ad30fded428d40bfe2edc728cdb2db72ca95526d1d5c5225d38eacacb6794b8c811b40988cac58480b0e74
|
7
|
+
data.tar.gz: 615e586767703298020a5d2a0172b9793a681a7cc681f45e6c6e42082e9db8c4a32f6e647fb006d8e9b7d8618b6c0dace7df6109cbca20e5e7fe0c34f991ed62
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,9 @@
|
|
1
1
|
## HEAD
|
2
2
|
|
3
|
+
## 8.0.1
|
4
|
+
|
5
|
+
- Bugfix: Lock and sleep and refresh API when duplicate app deletion detected (https://github.com/heroku/hatchet/pull/198)
|
6
|
+
|
3
7
|
## 8.0.0
|
4
8
|
|
5
9
|
- Breaking change: Delete apps on teardown. Previously hatchet would delete apps lazily to help with debugging. This behavior allowed developers to inspect logs and `heroku run bash` in the event of an unexpected failure. In practice, it is rarely needed and causes accounts to retain apps indefinitely. Previously there was no cost to retaining applications, but now `basic` applications incur a charge. Change details:
|
data/bin/hatchet
CHANGED
@@ -123,7 +123,11 @@ class HatchetCLI < Thor
|
|
123
123
|
when options[:older_than]
|
124
124
|
minutes = options[:older_than].to_i
|
125
125
|
puts "Destroying apps older than #{minutes}m"
|
126
|
-
reaper.destroy_older_apps(
|
126
|
+
reaper.destroy_older_apps(
|
127
|
+
minutes: minutes,
|
128
|
+
force_refresh: true,
|
129
|
+
on_conflict: :refresh_api_and_continue
|
130
|
+
)
|
127
131
|
puts "Done"
|
128
132
|
else
|
129
133
|
raise "No flags given run `hatchet help destroy` for options"
|
data/lib/hatchet/app.rb
CHANGED
@@ -280,14 +280,31 @@ module Hatchet
|
|
280
280
|
def create_app
|
281
281
|
3.times.retry do
|
282
282
|
begin
|
283
|
-
|
283
|
+
# Remove any obviously old apps first
|
284
|
+
# Try to use existing cache of apps to
|
285
|
+
# minimize API calls
|
286
|
+
@reaper.destroy_older_apps(
|
287
|
+
force_refresh: false,
|
288
|
+
on_conflict: :stop_if_under_limit,
|
289
|
+
)
|
284
290
|
hash = { name: name, stack: stack }
|
285
291
|
hash.delete_if { |k,v| v.nil? }
|
286
292
|
result = heroku_api_create_app(hash)
|
287
293
|
@heroku_id = result["id"]
|
288
294
|
rescue => e
|
289
|
-
|
290
|
-
|
295
|
+
# If we can't create an app assume
|
296
|
+
# it might be due to resource constraints
|
297
|
+
#
|
298
|
+
# Try to delete existing apps
|
299
|
+
@reaper.destroy_older_apps(
|
300
|
+
force_refresh: true,
|
301
|
+
on_conflict: :stop_if_under_limit,
|
302
|
+
)
|
303
|
+
# If we're still not under the limit, sleep a bit
|
304
|
+
# retry later.
|
305
|
+
@reaper.sleep_if_over_limit(
|
306
|
+
reason: "Could not create app #{e.message}"
|
307
|
+
)
|
291
308
|
raise e
|
292
309
|
end
|
293
310
|
end
|
data/lib/hatchet/reaper.rb
CHANGED
@@ -28,6 +28,13 @@ module Hatchet
|
|
28
28
|
DEFAULT_REGEX = /^#{Regexp.escape(Hatchet::APP_PREFIX)}[a-f0-9]+/
|
29
29
|
TTL_MINUTES = ENV.fetch("HATCHET_ALIVE_TTL_MINUTES", "7").to_i
|
30
30
|
|
31
|
+
# Protect against parallel deletion on the same machine
|
32
|
+
# via concurrent processes
|
33
|
+
#
|
34
|
+
# Does not protect against distributed systems on different
|
35
|
+
# machines trying to delete the same applications
|
36
|
+
MUTEX_FILE = File.open(File.join(Dir.tmpdir(), "hatchet_reaper_mutex"), File::CREAT)
|
37
|
+
|
31
38
|
attr_accessor :io, :hatchet_app_limit
|
32
39
|
|
33
40
|
def initialize(api_rate_limit: , regex: DEFAULT_REGEX, io: STDOUT, hatchet_app_limit: HATCHET_APP_LIMIT, initial_sleep: 10)
|
@@ -39,60 +46,127 @@ module Hatchet
|
|
39
46
|
@reaper_throttle = ReaperThrottle.new(initial_sleep: initial_sleep)
|
40
47
|
end
|
41
48
|
|
42
|
-
|
43
|
-
|
44
|
-
# by apps being over limit
|
45
|
-
def clean_old_or_sleep
|
46
|
-
# Protect against parallel deletion of the same app on the same system
|
47
|
-
mutex_file = File.open("#{Dir.tmpdir()}/hatchet_reaper_mutex", File::CREAT)
|
48
|
-
mutex_file.flock(File::LOCK_EX)
|
49
|
-
|
50
|
-
destroy_older_apps(force_refresh: true)
|
51
|
-
|
52
|
-
if @apps.length > @limit
|
49
|
+
def sleep_if_over_limit(reason: )
|
50
|
+
if @apps.length >= @limit
|
53
51
|
age = AppAge.new(created_at: @apps.last["created_at"], ttl_minutes: TTL_MINUTES)
|
54
52
|
@reaper_throttle.call(max_sleep: age.sleep_for_ttl) do |sleep_for|
|
55
53
|
io.puts <<-EOM.strip_heredoc
|
56
54
|
WARNING: Hatchet app limit reached (#{@apps.length}/#{@limit})
|
57
|
-
|
55
|
+
All known apps are younger than #{TTL_MINUTES} minutes.
|
56
|
+
Sleeping (#{sleep_for}s)
|
57
|
+
|
58
|
+
Reason: #{reason}
|
58
59
|
EOM
|
59
60
|
|
60
61
|
sleep(sleep_for)
|
61
62
|
end
|
62
63
|
end
|
63
|
-
ensure
|
64
|
-
mutex_file.close
|
65
64
|
end
|
66
65
|
|
67
66
|
# Destroys apps that are older than the given argument (expecting integer minutes)
|
68
|
-
|
67
|
+
#
|
68
|
+
# This method might be running concurrently on multiple processes or multiple
|
69
|
+
# machines.
|
70
|
+
#
|
71
|
+
# When a duplicate destroy is detected we can move forward with a conflict strategy:
|
72
|
+
#
|
73
|
+
# - `:refresh_api_and_continue`: Sleep to see if another process will clean up everything for
|
74
|
+
# us and then re-populate apps from the API and continue.
|
75
|
+
# - `:stop_if_under_limit`: Sleep to allow other processes to continue. Then if apps list
|
76
|
+
# is under the limit, assume someone else is already cleaning up for us and that we're
|
77
|
+
# good to move ahead to try to create an app. Otherwise if we're at or
|
78
|
+
# over the limit sleep, refresh the app list, and continue attempting to delete apps.
|
79
|
+
def destroy_older_apps(minutes: TTL_MINUTES, force_refresh: @apps.empty?, on_conflict: :refresh_api_and_continue)
|
80
|
+
MUTEX_FILE.flock(File::LOCK_EX)
|
81
|
+
|
69
82
|
refresh_app_list if force_refresh
|
70
83
|
|
71
|
-
@apps.
|
84
|
+
while app = @apps.pop
|
72
85
|
age = AppAge.new(created_at: app["created_at"], ttl_minutes: minutes)
|
73
|
-
if age.can_delete?
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
86
|
+
if !age.can_delete?
|
87
|
+
@apps.push(app)
|
88
|
+
break
|
89
|
+
else
|
90
|
+
begin
|
91
|
+
destroy_with_log(
|
92
|
+
id: app["id"],
|
93
|
+
name: app["name"],
|
94
|
+
reason: "app age (#{age.in_minutes}m) is older than #{minutes}m"
|
95
|
+
)
|
96
|
+
rescue AlreadyDeletedError => e
|
97
|
+
if handle_conflict(
|
98
|
+
strategy: on_conflict,
|
99
|
+
conflict_message: e.message,
|
100
|
+
) == :stop
|
101
|
+
break
|
102
|
+
end
|
103
|
+
end
|
79
104
|
end
|
80
|
-
rescue AlreadyDeletedError
|
81
|
-
# Ignore, keep going
|
82
105
|
end
|
106
|
+
ensure
|
107
|
+
MUTEX_FILE.flock(File::LOCK_UN)
|
83
108
|
end
|
84
109
|
|
85
110
|
# No guardrails, will delete all apps that match the hatchet namespace
|
86
111
|
def destroy_all(force_refresh: @apps.empty?)
|
112
|
+
MUTEX_FILE.flock(File::LOCK_EX)
|
113
|
+
|
87
114
|
refresh_app_list if force_refresh
|
88
115
|
|
89
|
-
@apps.
|
116
|
+
while app = @apps.pop
|
90
117
|
begin
|
91
118
|
destroy_with_log(name: app["name"], id: app["id"], reason: "destroy all")
|
92
|
-
rescue AlreadyDeletedError
|
93
|
-
|
119
|
+
rescue AlreadyDeletedError => e
|
120
|
+
handle_conflict(
|
121
|
+
conflict_message: e.message,
|
122
|
+
strategy: :refresh_api_and_continue
|
123
|
+
)
|
94
124
|
end
|
95
125
|
end
|
126
|
+
ensure
|
127
|
+
MUTEX_FILE.flock(File::LOCK_UN)
|
128
|
+
end
|
129
|
+
|
130
|
+
# Will sleep with backoff and emit a warning message
|
131
|
+
# returns :continue or :stop symbols
|
132
|
+
# :stop indicates execution should stop
|
133
|
+
private def handle_conflict(conflict_message:, strategy:)
|
134
|
+
message = String.new(<<-EOM.strip_heredoc)
|
135
|
+
WARNING: Possible race condition detected: #{conflict_message}
|
136
|
+
Hatchet app limit (#{@apps.length}/#{@limit}), using strategy #{strategy}
|
137
|
+
EOM
|
138
|
+
|
139
|
+
conflict_state = if :refresh_api_and_continue == strategy
|
140
|
+
message << "\nSleeping, refreshing app list, and continuing."
|
141
|
+
:continue
|
142
|
+
elsif :stop_if_under_limit == strategy && @apps.length >= @limit
|
143
|
+
message << "\nSleeping, refreshing app list, and continuing. Not under limit."
|
144
|
+
:continue
|
145
|
+
elsif :stop_if_under_limit == strategy
|
146
|
+
message << "\nHalting deletion of older apps. Under limit."
|
147
|
+
:stop
|
148
|
+
else
|
149
|
+
raise "No such strategy: #{strategy}, plese use :stop_if_under_limit or :refresh_api_and_continue"
|
150
|
+
end
|
151
|
+
|
152
|
+
@reaper_throttle.call(max_sleep: TTL_MINUTES) do |sleep_for|
|
153
|
+
io.puts <<-EOM.strip_heredoc
|
154
|
+
#{message}
|
155
|
+
Sleeping (#{sleep_for}s)
|
156
|
+
EOM
|
157
|
+
|
158
|
+
sleep(sleep_for)
|
159
|
+
end
|
160
|
+
|
161
|
+
case conflict_state
|
162
|
+
when :continue
|
163
|
+
refresh_app_list
|
164
|
+
when :stop
|
165
|
+
else
|
166
|
+
raise "Unknown state #{conflict_state}"
|
167
|
+
end
|
168
|
+
|
169
|
+
conflict_state
|
96
170
|
end
|
97
171
|
|
98
172
|
private def get_heroku_apps
|
@@ -117,15 +191,15 @@ module Hatchet
|
|
117
191
|
body = e.response.body
|
118
192
|
request_id = e.response.headers["Request-Id"]
|
119
193
|
if body =~ /Couldn\'t find that app./
|
120
|
-
|
121
|
-
raise AlreadyDeletedError.new
|
194
|
+
message = "Duplicate destroy attempted #{name.inspect}: #{id}, status: 404, request_id: #{request_id}"
|
195
|
+
raise AlreadyDeletedError.new(message)
|
122
196
|
else
|
123
197
|
raise e
|
124
198
|
end
|
125
199
|
rescue Excon::Error::Forbidden => e
|
126
200
|
request_id = e.response.headers["Request-Id"]
|
127
|
-
|
128
|
-
raise AlreadyDeletedError.new
|
201
|
+
message = "Duplicate destroy attempted #{name.inspect}: #{id}, status: 403, request_id: #{request_id}"
|
202
|
+
raise AlreadyDeletedError.new(message)
|
129
203
|
end
|
130
204
|
end
|
131
205
|
end
|
data/lib/hatchet/version.rb
CHANGED
data/spec/hatchet/app_spec.rb
CHANGED
@@ -43,7 +43,7 @@ describe "AppTest" do
|
|
43
43
|
|
44
44
|
reaper = app.reaper
|
45
45
|
|
46
|
-
def reaper.
|
46
|
+
def reaper.destroy_older_apps(*args, **kwargs, &block); @app_exception_message = true; end
|
47
47
|
def reaper.clean_old_was_called?; @app_exception_message; end
|
48
48
|
|
49
49
|
expect {
|
data/spec/unit/reaper_spec.rb
CHANGED
@@ -18,7 +18,7 @@ describe "Reaper" do
|
|
18
18
|
end
|
19
19
|
|
20
20
|
describe "cycle" do
|
21
|
-
it "does not delete anything if
|
21
|
+
it "does not delete anything if no old apps" do
|
22
22
|
reaper = Hatchet::Reaper.new(api_rate_limit: Object.new, hatchet_app_limit: 1, io: StringIO.new)
|
23
23
|
|
24
24
|
def reaper.get_heroku_apps
|
@@ -29,7 +29,7 @@ describe "Reaper" do
|
|
29
29
|
def reaper.check_get_heroku_apps_called; @called_get_heroku_apps ; end
|
30
30
|
def reaper.reap_once; raise "should not be called"; end
|
31
31
|
|
32
|
-
reaper.
|
32
|
+
reaper.destroy_older_apps
|
33
33
|
|
34
34
|
expect(reaper.check_get_heroku_apps_called).to be_truthy
|
35
35
|
end
|
@@ -46,7 +46,7 @@ describe "Reaper" do
|
|
46
46
|
end
|
47
47
|
def reaper.destroy_called_with; @reaper_destroy_called_with; end
|
48
48
|
|
49
|
-
reaper.
|
49
|
+
reaper.destroy_older_apps
|
50
50
|
|
51
51
|
expect(reaper.destroy_called_with).to eq({"name" => "hatchet-t-foo", "id" => 1})
|
52
52
|
end
|
@@ -54,10 +54,10 @@ describe "Reaper" do
|
|
54
54
|
it "sleeps, refreshes app list, and tries again when an old app is not past TTL" do
|
55
55
|
warning = StringIO.new
|
56
56
|
reaper = Hatchet::Reaper.new(
|
57
|
+
io: warning,
|
58
|
+
initial_sleep: 0,
|
57
59
|
api_rate_limit: Object.new,
|
58
60
|
hatchet_app_limit: 0,
|
59
|
-
initial_sleep: 0,
|
60
|
-
io: warning
|
61
61
|
)
|
62
62
|
|
63
63
|
def reaper.get_heroku_apps
|
@@ -74,7 +74,8 @@ describe "Reaper" do
|
|
74
74
|
|
75
75
|
def reaper.get_slept_for_val; @_slept_for; end
|
76
76
|
|
77
|
-
reaper.
|
77
|
+
reaper.destroy_older_apps
|
78
|
+
reaper.sleep_if_over_limit(reason: "test")
|
78
79
|
|
79
80
|
expect(reaper.get_slept_for_val).to eq(0)
|
80
81
|
expect(reaper.destroy_called_with).to eq(nil)
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: heroku_hatchet
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 8.0.
|
4
|
+
version: 8.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Richard Schneeman
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-03-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: platform-api
|