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 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