Skip to content

Commit 8b47c86

Browse files
authored
Merge pull request #22 from rails/rm-alert-on-stale-lock
Return system as down when the lock file is stale (older than 2 hours)
2 parents 6c511f2 + 95b9ef9 commit 8b47c86

File tree

13 files changed

+502
-39
lines changed

13 files changed

+502
-39
lines changed

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
11+
steps:
12+
- name: Checkout code
13+
uses: actions/checkout@v4
14+
15+
- name: Set up Ruby from .ruby-version
16+
uses: ruby/setup-ruby@v1
17+
with:
18+
bundler-cache: true
19+
20+
- name: Run tests
21+
run: bundle exec rake test

Gemfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@ gem "puma", "~> 6.0"
55

66
gem "capistrano3-puma"
77
gem "capistrano-rvm"
8+
9+
group :test do
10+
gem "minitest", "~> 5.0"
11+
gem "rack-test", "~> 2.0"
12+
end

Gemfile.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ GEM
2222
i18n (1.14.7)
2323
concurrent-ruby (~> 1.0)
2424
logger (1.7.0)
25+
minitest (5.26.0)
2526
net-scp (4.1.0)
2627
net-ssh (>= 2.6.5, < 8.0.0)
2728
net-sftp (4.0.0)
@@ -32,6 +33,8 @@ GEM
3233
puma (6.6.1)
3334
nio4r (~> 2.0)
3435
rack (2.2.20)
36+
rack-test (2.2.0)
37+
rack (>= 1.3)
3538
rake (13.3.0)
3639
sshkit (1.24.0)
3740
base64
@@ -47,8 +50,10 @@ PLATFORMS
4750
DEPENDENCIES
4851
capistrano-rvm
4952
capistrano3-puma
53+
minitest (~> 5.0)
5054
puma (~> 6.0)
5155
rack (~> 2.2)
56+
rack-test (~> 2.0)
5257

5358
BUNDLED WITH
5459
2.5.22

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,12 @@ Server management in encapsulated in the script `bin/server`:
99
bin/server stop
1010

1111
This webhook just touches a file meaning "we have been called". The docs server is responsible for monitoring the presence of said file somehow, and act accordingly.
12+
13+
## Testing
14+
15+
The application includes a comprehensive test suite using minitest and rack-test:
16+
17+
```bash
18+
# Run all tests
19+
bundle exec rake test
20+
```

Rakefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
require "rake/testtask"
4+
5+
Rake::TestTask.new(:test) do |t|
6+
t.libs << "test"
7+
t.libs << "."
8+
t.test_files = FileList["test/**/*_test.rb"]
9+
t.verbose = true
10+
end
11+
12+
task default: :test

config.ru

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# frozen_string_literal: true
22

3-
require "fileutils"
4-
require "rack"
53
require "logger"
4+
require "rack"
5+
require_relative "lib/rails_master_hook_app"
66

77
# Setup logger - log to STDOUT for systemd
8-
logger = Logger.new(STDOUT)
8+
logger = Logger.new($stdout)
99
logger.level = ENV["LOG_LEVEL"] ? Logger.const_get(ENV["LOG_LEVEL"].upcase) : Logger::INFO
1010
logger.formatter = proc do |severity, datetime, progname, msg|
1111
"#{severity}: #{msg}\n"
@@ -14,39 +14,4 @@ end
1414
# Use Rack::CommonLogger for HTTP request logging
1515
use Rack::CommonLogger, logger
1616

17-
run_file = ENV["RUN_FILE"] || "#{__dir__}/run-rails-master-hook"
18-
scheduled = <<EOS
19-
Rails master hook tasks scheduled:
20-
21-
* updates the local checkout
22-
* updates Rails Contributors
23-
* generates and publishes edge docs
24-
25-
If a new stable tag is detected it also
26-
27-
* generates and publishes stable docs
28-
29-
This needs typically a few minutes.
30-
EOS
31-
32-
map "/rails-master-hook" do
33-
run ->(env) do
34-
request_method = env["REQUEST_METHOD"]
35-
36-
if request_method == "POST"
37-
logger.info "Triggering Rails master hook by touching #{run_file}"
38-
FileUtils.touch(run_file)
39-
logger.info "Rails master hook scheduled successfully"
40-
[200, {"Content-Type" => "text/plain", "Content-Length" => scheduled.length.to_s}, [scheduled]]
41-
else
42-
logger.warn "Rejected non-POST request (#{request_method}) to /rails-master-hook"
43-
[404, {"Content-Type" => "text/plain", "Content-Length" => "0"}, []]
44-
end
45-
end
46-
end
47-
48-
map "/" do
49-
run ->(_env) do
50-
[200, {"Content-Type" => "text/plain", "Content-Length" => "4"}, ["PONG"]]
51-
end
52-
end
17+
run RailsMasterHookApp.new(logger: logger)

config/deploy/production.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
set :puma_service_unit_env_vars, %w[
1010
RUN_FILE=/home/rails/rails-master-hook/run-rails-master-hook
11+
LOCK_FILE=/home/rails/rails-master-hook/lock-rails-master-hook
1112
]
1213
set :puma_access_log, "journal"
1314
set :puma_error_log, "journal"

lib/lockfile_checker.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
# Utility class for checking if lock files are stale
4+
#
5+
# A lock file is considered stale if it's older than 2 hours,
6+
# which indicates a potentially stuck or long-running process.
7+
class LockfileChecker
8+
# @param lock_file [String, nil] Path to the lock file to check
9+
def initialize(lock_file)
10+
@file_age_seconds = calculate_file_age(lock_file)
11+
end
12+
13+
# Check if the lock file is stale (older than 2 hours)
14+
#
15+
# @return [Boolean] true if the lock file is stale, false otherwise
16+
def stale?
17+
return false if @file_age_seconds.nil?
18+
19+
@file_age_seconds > 7200 # 2 hours in seconds
20+
end
21+
22+
# Get the age of the lock file in minutes
23+
#
24+
# @return [Float, nil] Age in minutes, or nil if file doesn't exist
25+
def age_in_minutes
26+
return nil if @file_age_seconds.nil?
27+
28+
(@file_age_seconds / 60).round(1)
29+
end
30+
31+
private
32+
33+
def calculate_file_age(lock_file)
34+
return nil unless lock_file && File.exist?(lock_file)
35+
36+
Time.now - File.mtime(lock_file)
37+
end
38+
end

lib/rails_master_hook_app.rb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
# Application wrapper for testing
4+
# This extracts the core application logic without loading config.ru
5+
6+
require "fileutils"
7+
require "rack"
8+
require "logger"
9+
require_relative "lockfile_checker"
10+
11+
class RailsMasterHookApp
12+
def initialize(run_file: nil, lock_file: nil, logger:)
13+
@run_file = run_file || ENV["RUN_FILE"] || File.expand_path("../run-rails-master-hook", __dir__)
14+
@lock_file = lock_file || ENV["LOCK_FILE"]
15+
@logger = logger
16+
end
17+
18+
def call(env)
19+
request = Rack::Request.new(env)
20+
21+
# Handle rails-master-hook routes (with or without trailing slash)
22+
if request.path_info == "/rails-master-hook" || request.path_info == "/rails-master-hook/"
23+
handle_rails_master_hook(request)
24+
else
25+
handle_root(request)
26+
end
27+
end
28+
29+
private
30+
31+
def handle_rails_master_hook(request)
32+
if request.request_method == "POST"
33+
@logger.info "Triggering Rails master hook by touching #{@run_file}"
34+
FileUtils.touch(@run_file)
35+
@logger.info "Rails master hook scheduled successfully"
36+
37+
scheduled = <<~EOS
38+
Rails master hook tasks scheduled:
39+
40+
* updates the local checkout
41+
* updates Rails Contributors
42+
* generates and publishes edge docs
43+
44+
If a new stable tag is detected it also
45+
46+
* generates and publishes stable docs
47+
48+
This needs typically a few minutes.
49+
EOS
50+
51+
[200, {"Content-Type" => "text/plain", "Content-Length" => scheduled.length.to_s}, [scheduled]]
52+
else
53+
@logger.warn "Rejected non-POST request (#{request.request_method}) to /rails-master-hook"
54+
[404, {"Content-Type" => "text/plain", "Content-Length" => "0"}, []]
55+
end
56+
end
57+
58+
def handle_root(request)
59+
lockfile_checker = LockfileChecker.new(@lock_file)
60+
61+
if lockfile_checker.stale?
62+
age_minutes = lockfile_checker.age_in_minutes
63+
error_msg = "System down: Lock file has been present for more than 2 hours"
64+
@logger.error "#{error_msg} (actual age: #{age_minutes} minutes)"
65+
[503, {"Content-Type" => "text/plain", "Content-Length" => error_msg.length.to_s}, [error_msg]]
66+
else
67+
[200, {"Content-Type" => "text/plain", "Content-Length" => "4"}, ["PONG"]]
68+
end
69+
end
70+
end

test/integration_test.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "test_helper"
4+
5+
class IntegrationTest < TestCase
6+
def test_config_ru_app_behaves_correctly
7+
app, _options = Rack::Builder.parse_file(File.expand_path("../config.ru", __dir__))
8+
9+
env = Rack::MockRequest.env_for("/")
10+
status, headers, body = app.call(env)
11+
12+
assert_equal 200, status
13+
assert_equal "PONG", body.first
14+
assert_equal "text/plain", headers["Content-Type"]
15+
end
16+
17+
def test_config_ru_rails_master_hook_endpoint
18+
capture_io do
19+
app, _options = Rack::Builder.parse_file(File.expand_path("../config.ru", __dir__))
20+
21+
env = Rack::MockRequest.env_for("/rails-master-hook", method: "POST")
22+
status, headers, body = app.call(env)
23+
24+
assert_equal 200, status
25+
assert_match(/Rails master hook tasks scheduled/, body.first)
26+
assert_equal "text/plain", headers["Content-Type"]
27+
end
28+
end
29+
end

0 commit comments

Comments
 (0)