heroku_hatchet 8.0.0 → 8.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e66d5aea4901cc03c37bbf6d0f4b567e49f9c170d471ff461cc642956a195e9
4
- data.tar.gz: 79fb324886b6a90828a753a9e93679db0386d1696f081efc33b05fef8d2fd5ab
3
+ metadata.gz: f4233a137df7685f1e411471db8c53a72c9c6e77e5b9a901d0d8bb1c0147f06a
4
+ data.tar.gz: ffd93abf69b7a46ccbdda5148710e237b9c41171abef209ea56f187d7a52c501
5
5
  SHA512:
6
- metadata.gz: d337d285c3c35fc705e2f715bf209df425a1d36e908cba200610123b41eab488a59439109634c2456c148d6f877d93e9910becfde2606582803853bd9bce5913
7
- data.tar.gz: 9c601a90d90d82782fd4fcdfca05ca1030738a1a0e93bd73c0cc42c3f60b91b7094b49b9128a3f4358ad4d161aceb2206850f90f155f2a1973529e705a00e345
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(minutes: minutes)
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
- @reaper.destroy_older_apps
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
- puts "Warning: Could not create app #{e.message}"
290
- @reaper.clean_old_or_sleep
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
@@ -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
- # Called when we need an app, but are over limit or
43
- # if an exception has occured that was possibly triggered
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
- All known apps are younger than #{TTL_MINUTES} minutes
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
- def destroy_older_apps(minutes: TTL_MINUTES, force_refresh: @apps.empty?)
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.each do |app|
84
+ while app = @apps.pop
72
85
  age = AppAge.new(created_at: app["created_at"], ttl_minutes: minutes)
73
- if age.can_delete?
74
- destroy_with_log(
75
- name: app["name"],
76
- id: app["id"],
77
- reason: "app age (#{age.in_minutes}m) is older than #{minutes}m"
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.each do |app|
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
- # Ignore, keep going
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
- io.puts "Duplicate destroy attempted #{name.inspect}: #{id}, status: 404, request_id: #{request_id}"
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
- io.puts "Duplicate destroy attempted #{name.inspect}: #{id}, status: 403, request_id: #{request_id}"
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
@@ -1,3 +1,3 @@
1
1
  module Hatchet
2
- VERSION = "8.0.0"
2
+ VERSION = "8.0.1"
3
3
  end
@@ -43,7 +43,7 @@ describe "AppTest" do
43
43
 
44
44
  reaper = app.reaper
45
45
 
46
- def reaper.clean_old_or_sleep; @app_exception_message = true; end
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 {
@@ -18,7 +18,7 @@ describe "Reaper" do
18
18
  end
19
19
 
20
20
  describe "cycle" do
21
- it "does not delete anything if under the limit" do
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.clean_old_or_sleep
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.clean_old_or_sleep
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.clean_old_or_sleep
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.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-02-27 00:00:00.000000000 Z
11
+ date: 2023-03-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: platform-api