Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/environments/production.rb
Original file line number Diff line number Diff line change
Expand Up @@ -246,5 +246,9 @@
# This greatly simplifies backup, maintenance, etc. If you wanted the
# jobs to go into their own database, you'd need to do something like:
# config.solid_queue.connects_to = { database: { writing: :queue } }

# Periodically run GC.compact to reduce memory fragmentation
require_relative '../../lib/gc_compact_middleware'
config.middleware.use GcCompactMiddleware
end
# rubocop:enable Metrics/BlockLength
55 changes: 55 additions & 0 deletions lib/gc_compact_middleware.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

# Copyright the Linux Foundation and the
# OpenSSF Best Practices badge contributors
# SPDX-License-Identifier: MIT

# Middleware to periodically run GC.compact to reduce memory fragmentation.
# Compaction runs after response is sent to client (no user-facing latency).
# Frequency controlled by BADGEAPP_GC_COMPACT_MINUTES (default: 120 minutes).
# Note that this class has a singleton instance
class GcCompactMiddleware
def initialize(app)
@app = app
@last_compact_time = Time.zone.now
@interval = (ENV['BADGEAPP_GC_COMPACT_MINUTES'] || 120).to_i * 60
@mutex = Mutex.new
end

def call(env)
response = @app.call(env)
schedule_compact(env) if time_to_compact?
response
end

private

def time_to_compact?
Time.zone.now - @last_compact_time >= @interval
end

# This method handles multiple threads. Here's how:
# 1. Multiple threads see time_to_compact? returns true
# 2. They all call schedule_compact(env)
# 3. First thread acquires mutex, updates @last_compact_time, and
# sets scheduled = true
# 4. Other threads wait, then see the time is already updated, and skip
# scheduling
# 5. Only one compaction gets scheduled
def schedule_compact(env)
scheduled = false
@mutex.synchronize do
if Time.zone.now - @last_compact_time >= @interval
@last_compact_time = Time.zone.now
scheduled = true
end
end
(env['rack.after_reply'] ||= []) << -> { compact } if scheduled
end

def compact
Rails.logger.info 'GC.compact started'
GC.compact
Rails.logger.info 'GC.compact completed'
end
end
65 changes: 65 additions & 0 deletions test/lib/gc_compact_middleware_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

# Copyright the Linux Foundation, IDA, and the
# OpenSSF Best Practices badge contributors
# SPDX-License-Identifier: MIT

require 'test_helper'
require 'gc_compact_middleware'

# Test our garbage collection compactor.
class GcCompactMiddlewareTest < ActiveSupport::TestCase
test 'checks time correctly' do
app = ->(_env) { [200, {}, ['OK']] }
middleware = GcCompactMiddleware.new(app)

# Should not compact immediately after initialization
assert_not middleware.send(:time_to_compact?)

# Should compact after interval expires
middleware.instance_variable_set(:@last_compact_time, Time.zone.now - 7200)
assert middleware.send(:time_to_compact?)
end

test 'compact runs without error' do
app = ->(_env) { [200, {}, ['OK']] }
middleware = GcCompactMiddleware.new(app)

assert_nothing_raised do
middleware.send(:compact)
end
end

test 'schedule_compact schedules after interval' do
app = ->(_env) { [200, {}, ['OK']] }
middleware = GcCompactMiddleware.new(app)
env = { 'rack.after_reply' => [] }

# Set time to past so interval has expired
middleware.instance_variable_set(:@last_compact_time, Time.zone.now - 7200)

middleware.send(:schedule_compact, env)

# Should have scheduled a callback
assert_equal 1, env['rack.after_reply'].length
end

test 'call method returns response and schedules when interval expired' do
app = ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['OK']] }
middleware = GcCompactMiddleware.new(app)
env = { 'rack.after_reply' => [] }

# Set time to past so interval has expired
middleware.instance_variable_set(:@last_compact_time, Time.zone.now - 7200)

status, headers, body = middleware.call(env)

# Should return app response
assert_equal 200, status
assert_equal({ 'Content-Type' => 'text/plain' }, headers)
assert_equal ['OK'], body

# Should have scheduled compaction
assert_equal 1, env['rack.after_reply'].length
end
end