Skip to content

Add Redis session storage support #343

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
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
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ruby 3.3.3
32 changes: 32 additions & 0 deletions README.markdown
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

<p align=right>
<strong>Current version: 4.2.1</strong> | Documentation for:
<a href=https://github.com/rails/web-console/tree/v1.0.4>v1.0.4</a>
Expand Down Expand Up @@ -129,6 +131,26 @@ Rails.application.configure do
end
```

### config.web_console.use_redis_storage

By default, _Web Console_ uses Redis for session storage to fix the "Session is no longer available in memory" error when using multi-process servers like Puma or Unicorn.

You can disable Redis storage and fall back to in-memory storage:

```ruby
Rails.application.configure do
config.web_console.use_redis_storage = false
end
```

When Redis storage is enabled, sessions are stored with a 1-hour TTL. The Redis connection URL can be configured via:

- `Rails.application.secrets[:redis_url]`
- `ENV['REDIS_CONNECTION_URL_DEV']` (for development)
- `ENV['REDIS_CONNECTION_URL_PRO']` (for production)
- `ENV['REDIS_URL']` (fallback)
- Default: `redis://localhost:6379/0`

## FAQ

### Where did /console go?
Expand All @@ -147,6 +169,16 @@ different worker (process) that doesn't have the desired session in memory.
To avoid that, if you use such servers in development, configure them so they
serve requests only out of one process.

**Redis Session Storage Solution:**

_Web Console_ now supports Redis-based session storage to solve this problem. When enabled (default), sessions are stored in Redis with a 1-hour TTL, allowing sessions to persist across different worker processes.

To use Redis session storage:

1. Ensure Redis is running
2. Configure your Redis connection URL (see configuration section above)
3. Redis storage is enabled by default, but you can disable it with `config.web_console.use_redis_storage = false`

#### Passenger

Enable sticky sessions for [Passenger on Nginx] or [Passenger on Apache] to
Expand Down
1 change: 1 addition & 0 deletions lib/web_console.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module WebConsole
autoload :Middleware
autoload :Context
autoload :SourceLocation
autoload :RedisSessionStorage

autoload_at "web_console/errors" do
autoload :Error
Expand Down
7 changes: 7 additions & 0 deletions lib/web_console/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ def web_console_permissions
end
end

initializer "web_console.redis_session_storage" do
# Configure Redis session storage
if config.web_console.key?(:use_redis_storage)
Session.use_redis_storage = config.web_console.use_redis_storage
end
end

initializer "i18n.load_path" do
config.i18n.load_path.concat(Dir[File.expand_path("../locales/*.yml", __FILE__)])
end
Expand Down
57 changes: 57 additions & 0 deletions lib/web_console/redis_session_storage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

require "redis"
require "json"

module WebConsole
# Redis-based session storage for web-console
# This fixes the "Session is no longer available in memory" error
# when using multi-process servers like Puma or Unicorn
class RedisSessionStorage
class << self
def redis
@redis ||= begin
url = redis_url
Redis.new(url: url, reconnect_attempts: 3, timeout: 5)
end
end

def redis_url
if defined?(Rails) && Rails.application
if Rails.application.respond_to?(:secrets) && Rails.application.secrets.respond_to?(:[]) && Rails.application.secrets[:redis_url]
Rails.application.secrets[:redis_url]
else
ENV['REDIS_CONNECTION_URL_DEV'] || \
ENV['REDIS_CONNECTION_URL_PRO'] || \
ENV['REDIS_URL'] || "redis://localhost:6379/0"
end
else
ENV['REDIS_URL'] || "redis://localhost:6379/0"
end
end

def store(id, session_data)
redis.setex("web_console:session:#{id}", 3600, session_data.to_json)
end

def find(id)
data = redis.get("web_console:session:#{id}")
return nil unless data

begin
JSON.parse(data, symbolize_names: true)
rescue JSON::ParserError
nil
end
end

def delete(id)
redis.del("web_console:session:#{id}")
end

def cleanup_expired
# Redis automatically expires keys, so no manual cleanup needed
end
end
end
end
50 changes: 49 additions & 1 deletion lib/web_console/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,31 @@ module WebConsole
# that.
class Session
cattr_reader :inmemory_storage, default: {}
cattr_accessor :use_redis_storage, default: true

class << self
# Finds a persisted session in memory by its id.
#
# Returns a persisted session if found in memory.
# Raises NotFound error unless found in memory.
def find(id)
inmemory_storage[id]
if use_redis_storage
find_in_redis(id)
else
inmemory_storage[id]
end
end

# Find a session in Redis storage
def find_in_redis(id)
session_data = RedisSessionStorage.find(id)
return nil unless session_data

# Reconstruct the session from stored data
reconstruct_session_from_data(session_data)
rescue => e
WebConsole.logger.error("Failed to retrieve session from Redis: #{e.message}")
nil
end

# Create a Session from an binding or exception in a storage.
Expand All @@ -36,6 +53,19 @@ def from(storage)
new([[binding]])
end
end

private

def reconstruct_session_from_data(session_data)
# Create a new session with the stored exception mappers
exception_mappers = session_data[:exception_mappers].map do |mapper_data|
ExceptionMapper.new(mapper_data[:exception])
end

session = new(exception_mappers)
session.instance_variable_set(:@id, session_data[:id])
session
end
end

# An unique identifier for every REPL.
Expand All @@ -48,6 +78,7 @@ def initialize(exception_mappers)
@evaluator = Evaluator.new(@current_binding = exception_mappers.first.first)

store_into_memory
store_into_redis if self.class.use_redis_storage
end

# Evaluate +input+ on the current Evaluator associated binding.
Expand Down Expand Up @@ -76,5 +107,22 @@ def context(objpath)
def store_into_memory
inmemory_storage[id] = self
end

def store_into_redis
session_data = {
id: @id,
exception_mappers: @exception_mappers.map do |mapper|
{
exception: mapper.exc,
backtrace: mapper.exc.backtrace,
bindings: mapper.exc.bindings
}
end
}

RedisSessionStorage.store(@id, session_data)
rescue => e
WebConsole.logger.error("Failed to store session in Redis: #{e.message}")
end
end
end
93 changes: 93 additions & 0 deletions test/web_console/redis_session_storage_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# frozen_string_literal: true

require "test_helper"

module WebConsole
class RedisSessionStorageTest < ActiveSupport::TestCase
setup do
# Clear any existing Redis keys for this test
RedisSessionStorage.redis.flushdb if RedisSessionStorage.redis
end

teardown do
# Clean up Redis after each test
RedisSessionStorage.redis.flushdb if RedisSessionStorage.redis
end

test "redis_url returns default when no Rails app" do
ENV['REDIS_URL'] = nil
assert_equal "redis://localhost:6379/0", RedisSessionStorage.redis_url
end

test "redis_url returns ENV REDIS_URL when set" do
ENV['REDIS_URL'] = "redis://custom:6380/1"
assert_equal "redis://custom:6380/1", RedisSessionStorage.redis_url
ensure
ENV['REDIS_URL'] = nil
end

test "store and find session data" do
session_id = "test_session_123"
session_data = { id: session_id, test: "data" }

RedisSessionStorage.store(session_id, session_data)
retrieved_data = RedisSessionStorage.find(session_id)

assert_equal session_data, retrieved_data
end

test "find returns nil for non-existent session" do
assert_nil RedisSessionStorage.find("non_existent_session")
end

test "delete removes session data" do
session_id = "test_session_456"
session_data = { id: session_id, test: "data" }

RedisSessionStorage.store(session_id, session_data)
assert RedisSessionStorage.find(session_id)

RedisSessionStorage.delete(session_id)
assert_nil RedisSessionStorage.find(session_id)
end

test "session data expires after TTL" do
session_id = "test_session_789"
session_data = { id: session_id, test: "data" }

RedisSessionStorage.store(session_id, session_data)
assert RedisSessionStorage.find(session_id)

# Wait for expiration (Redis TTL is 3600 seconds, but we can't wait that long in tests)
# This test verifies the TTL is set correctly
ttl = RedisSessionStorage.redis.ttl("web_console:session:#{session_id}")
assert ttl > 0, "TTL should be set"
assert ttl <= 3600, "TTL should not exceed 3600 seconds"
end

test "handles JSON parsing errors gracefully" do
session_id = "test_session_invalid"

# Store invalid JSON directly in Redis
RedisSessionStorage.redis.set("web_console:session:#{session_id}", "invalid json")

assert_nil RedisSessionStorage.find(session_id)
end

test "redis connection with custom URL" do
original_url = RedisSessionStorage.redis_url

begin
# Test with a custom URL (this won't actually connect in test environment)
RedisSessionStorage.instance_variable_set(:@redis, nil)
ENV['REDIS_URL'] = "redis://test:6379/0"

# Should not raise an error
assert RedisSessionStorage.redis
ensure
ENV['REDIS_URL'] = nil
RedisSessionStorage.instance_variable_set(:@redis, nil)
end
end
end
end
60 changes: 60 additions & 0 deletions test/web_console/session_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,65 @@ def source_location

assert_equal "=> WebConsole::SessionTest::ValueAwareError\n", session.eval("self")
end

# Redis session storage tests
test "stores session in Redis when use_redis_storage is true" do
Session.use_redis_storage = true

session = Session.new([[binding]])

# Verify session is stored in Redis
redis_data = RedisSessionStorage.find(session.id)
assert redis_data
assert_equal session.id, redis_data[:id]
end

test "does not store session in Redis when use_redis_storage is false" do
Session.use_redis_storage = false

session = Session.new([[binding]])

# Verify session is not stored in Redis
redis_data = RedisSessionStorage.find(session.id)
assert_nil redis_data
end

test "can find session from Redis when use_redis_storage is true" do
Session.use_redis_storage = true

# Create a session that gets stored in Redis
original_session = Session.new([[binding]])
session_id = original_session.id

# Clear in-memory storage to simulate different process
Session.inmemory_storage.clear

# Find session from Redis
found_session = Session.find(session_id)
assert found_session
assert_equal session_id, found_session.id
end

test "handles Redis connection errors gracefully" do
Session.use_redis_storage = true

# Mock Redis to raise an error
RedisSessionStorage.stubs(:find).raises(Redis::BaseConnectionError.new("Connection failed"))

# Should return nil instead of raising an error
assert_nil Session.find("some_session_id")
end

test "handles Redis storage errors gracefully" do
Session.use_redis_storage = true

# Mock Redis to raise an error during storage
RedisSessionStorage.stubs(:store).raises(Redis::BaseConnectionError.new("Connection failed"))

# Should not raise an error when creating session
assert_nothing_raised do
Session.new([[binding]])
end
end
end
end
1 change: 1 addition & 0 deletions web-console.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ Gem::Specification.new do |s|
s.add_dependency "railties", rails_version
s.add_dependency "actionview", rails_version
s.add_dependency "bindex", ">= 0.4.0"
s.add_dependency "redis", ">= 4.0.0"
end
Loading