fly-ruby 0.2.1 → 0.4.0

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: 79850f3de9e2a7e78ff9d46ba7728c605fae5651786d3a425dec0d12056d75b6
4
- data.tar.gz: 5349dfbb16a8a257a909c68410f2041bf5202bbeed5ae88e496a9efa2e1c9478
3
+ metadata.gz: ea1ca64bcd11ed8a60774f0d8ec255ffb49bc6b8e21a8bd32e7410068bec35c0
4
+ data.tar.gz: fb55ad6c98cb631bf202aea206c53a9b2e9a7c738cd9835095dd647011cfc7f6
5
5
  SHA512:
6
- metadata.gz: e43ad67e8d81f5d817ad274bb0f2e2a97d276fc1a9f0bb78549ea6cc06b5c2e5a90fd2ddd03f7529f592cf037bed68ed9f172945425829b7952acbdd4c20df58
7
- data.tar.gz: d91c4fd6e1a5d3619165c9a44076840ac331c8533f0cb94f34001022d987bca1f7b654de34668cc6029305341f39bc7caa22b31ad76968802261fd884816b878
6
+ metadata.gz: 7c3df04f8bb9f97bfb87903b0fffc14ef2b320802e1183752c07f8b769b629f6c66af21ae0e4bb5a23a2608cfdfdda2f3b1c387e3edcd1f64b5d2474a79f7a6f
7
+ data.tar.gz: ff4ca7c18d4b18ec237c8e1c59bce88dce849198934ed1d42c28ab2a081b577f8fa2cc38930d0478ec94a5bee580e2f12649b38d3ad46d176a774edaba60446c
data/.gitignore CHANGED
@@ -4,3 +4,4 @@ log/
4
4
  Gemfile.lock
5
5
  .ruby-version
6
6
  *.gem
7
+ db/
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ### Bug fixes
4
4
 
5
+ - Run the database exception handler at the bottom of the stack to ensure it will take priority over other exception handlers
6
+
7
+ ## 0.2.1
8
+
9
+ ### Bug fixes
10
+
5
11
  - Only hijack the database connection for requests in secondary regions
6
12
 
7
13
  ## 0.2.0
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  [![Test](https://github.com/superfly/fly-ruby/actions/workflows/test.yml/badge.svg)](https://github.com/superfly/fly-ruby/actions/workflows/test.yml)
2
2
 
3
- This gem contains helper code and Rack middleware for deploying Ruby web apps on [Fly.io](https://fly.io). Supported features:
3
+ This gem contains helper code and Rack middleware for deploying Ruby web apps on [Fly.io](https://fly.io). It's designed to speed up apps by using region-local Postgresql replicas for database reads. See the blog post for more details:
4
4
 
5
- * Speed up apps by using region-local Postgresql replicas for database reads
5
+ https://fly.io/blog/run-ordinary-rails-apps-globally
6
6
 
7
7
  ## Speed up apps using region-local database replicas
8
8
 
@@ -49,7 +49,7 @@ Fly.configure do |c|
49
49
  end
50
50
  ```
51
51
 
52
- See [the source code](https://github.com/soupedup/fly-rails/blob/main/lib/fly-rails/configuration.rb) for defaults and available configuration options.
52
+ See [the source code](https://github.com/superfly/fly-ruby/blob/main/lib/fly-ruby/configuration.rb) for defaults and available configuration options.
53
53
  ## Known issues
54
54
 
55
55
  This middleware send all requests to the primary if you do something like update a user's database session on every GET request.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Fly
2
4
  class Configuration
3
5
  # Set the region where this instance of the application is deployed
@@ -15,6 +17,7 @@ module Fly
15
17
  attr_accessor :database_url_env_var
16
18
  attr_accessor :database_host_env_var
17
19
  attr_accessor :database_port_env_var
20
+ attr_accessor :redis_url_env_var
18
21
 
19
22
  # Cookie written and read by this middleware storing a UNIX timestamp.
20
23
  # Requests arriving before this timestamp will be replayed in the primary region.
@@ -25,33 +28,77 @@ module Fly
25
28
  attr_accessor :replay_threshold_in_seconds
26
29
 
27
30
  attr_accessor :database_url
31
+ attr_accessor :redis_url
32
+
33
+ # An array of string representations of exceptions that should trigger a replay
34
+ attr_accessor :replayable_exceptions
28
35
 
29
36
  def initialize
30
37
  self.primary_region = ENV["PRIMARY_REGION"]
31
38
  self.current_region = ENV["FLY_REGION"]
32
39
  self.replay_http_methods = ["POST", "PUT", "PATCH", "DELETE"]
33
40
  self.database_url_env_var = "DATABASE_URL"
41
+ self.redis_url_env_var = "REDIS_URL"
34
42
  self.database_host_env_var = "DATABASE_HOST"
35
43
  self.database_port_env_var = "DATABASE_PORT"
36
44
  self.replay_threshold_cookie = "fly-replay-threshold"
37
45
  self.replay_threshold_in_seconds = 5
38
46
  self.database_url = ENV[database_url_env_var]
47
+ self.redis_url = ENV[redis_url_env_var]
48
+ self.replayable_exceptions = ["SQLite3::CantOpenException", "PG::ReadOnlySqlTransaction"]
49
+ end
50
+
51
+ def replayable_exception_classes
52
+ @replayable_exception_classes ||= replayable_exceptions.collect {|ex| module_exists?(ex) }.compact
53
+ @replayable_exception_classes
54
+ end
55
+
56
+ def module_exists?(module_name)
57
+ mod = Module.const_get(module_name)
58
+ return mod
59
+ rescue NameError
60
+ nil
61
+ end
62
+
63
+ def database_uri
64
+ @database_uri ||= URI.parse(database_url)
65
+ @database_uri
39
66
  end
40
67
 
41
- def regional_database_uri
42
- @uri ||= URI.parse(database_url)
43
- @uri
68
+ def regional_database_url
69
+ uri = database_uri.dup
70
+ uri.host = regional_database_host
71
+ uri.to_s
72
+ end
73
+
74
+ def regional_database_host
75
+ "#{current_region}.#{database_uri.hostname}"
44
76
  end
45
77
 
46
78
  # Rails-compatible database configuration
47
79
  def regional_database_config
48
80
  {
49
- "host" => "#{current_region}.#{regional_database_uri.hostname}",
50
- "port" => 5433,
51
- "adapter" => "postgresql"
81
+ :host => regional_database_host,
82
+ :port => 5433,
83
+ :adapter => "postgresql"
52
84
  }
53
85
  end
54
86
 
87
+ def redis_uri
88
+ @redis_uri ||= URI.parse(redis_url)
89
+ @redis_uri
90
+ end
91
+
92
+ def regional_redis_host
93
+ "#{current_region}.#{redis_uri.hostname}"
94
+ end
95
+
96
+ def regional_redis_url
97
+ uri = redis_uri.dup
98
+ uri.host = regional_redis_host
99
+ uri.to_s
100
+ end
101
+
55
102
  def eligible_for_activation?
56
103
  database_url && primary_region && current_region && web?
57
104
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fly
4
+ class Headers
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ status, headers, body = @app.call(env)
11
+ response = Rack::Response.new(body, status, headers)
12
+ response.set_header('Fly-Region', ENV['FLY_REGION'])
13
+ response.finish
14
+ end
15
+ end
16
+ end
@@ -1,4 +1,4 @@
1
- require_relative '../fly-ruby'
1
+ # frozen_string_literal: true
2
2
 
3
3
  class Fly::Railtie < Rails::Railtie
4
4
  def hijack_database_connection
@@ -8,28 +8,23 @@ class Fly::Railtie < Rails::Railtie
8
8
  # hooks for forking servers to work correctly.
9
9
  if defined?(ActiveRecord)
10
10
  config = ActiveRecord::Base.connection_db_config.configuration_hash
11
- ActiveRecord::Base.establish_connection(config.merge(Fly.configuration.regional_database_config))
11
+ ActiveRecord::Base.establish_connection(config.symbolize_keys.merge(Fly.configuration.regional_database_config))
12
12
  end
13
13
  end
14
14
  end
15
15
 
16
- # Set useful headers for debugging
17
- def set_debug_response_headers
18
- return unless defined?(ApplicationController)
19
-
20
- ApplicationController.send(:after_action) do
21
- response.headers['Fly-Region'] = ENV['FLY_REGION']
22
- response.headers['Fly-Database-Host'] = Fly.configuration.regional_database_config["host"]
23
- end
24
- end
25
-
26
16
  initializer("fly.regional_database") do |app|
27
- set_debug_response_headers
17
+ # Insert the request middleware high in the stack, but after static file delivery
18
+ app.config.middleware.insert_after ActionDispatch::Executor, Fly::Headers if Fly.configuration.web?
19
+
28
20
  if Fly.configuration.eligible_for_activation?
29
- app.config.middleware.insert_after ActionDispatch::Executor, Fly::RegionalDatabase
21
+ app.config.middleware.insert_after Fly::Headers, Fly::RegionalDatabase::ReplayableRequestMiddleware
22
+ # Insert the database exception handler at the bottom of the stack to take priority over other exception handlers
23
+ app.config.middleware.use Fly::RegionalDatabase::DbExceptionHandlerMiddleware
30
24
 
31
- # Run the middleware high in the stack, but after static file delivery
32
- hijack_database_connection if Fly.configuration.in_secondary_region?
25
+ if Fly.configuration.in_secondary_region?
26
+ hijack_database_connection
27
+ end
33
28
  elsif Fly.configuration.web?
34
29
  puts "Warning: DATABASE_URL, PRIMARY_REGION and FLY_REGION must be set to activate the fly-ruby middleware. Middleware not loaded."
35
30
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rake'
2
4
 
3
5
  module Fly
@@ -5,39 +7,7 @@ module Fly
5
7
  # multithreaded environments. Instead of using dirty tricks like using Object#dup,
6
8
  # values are passed to methods.
7
9
 
8
- class RegionalDatabase
9
- def initialize(app)
10
- @app = app
11
- end
12
-
13
- # Overwrite the database connection string environment variable
14
- # to prefer connections to the regional replica.
15
- #
16
- # For Rails apps, this process will be repeated at middleware insertion time,
17
- # to support situations where the database is already accessed by other
18
- # initialization code. See Fly::Railtie.
19
-
20
- def prefer_regional_database!
21
- return if Fly.configuration.web?
22
-
23
- uri = Fly.configuration.database_uri
24
-
25
- ENV[Fly.configuration.database_url_env_var] = uri.to_s
26
- ENV[Fly.configuration.database_host_env_var] = uri.hostname
27
- ENV[Fly.configuration.database_port_env_var] = uri.port.to_s
28
- end
29
-
30
- def in_primary_region?
31
- Fly.configuration.primary_region == Fly.configuration.current_region
32
- end
33
-
34
- def regional_database_url
35
- end
36
-
37
- def response_body
38
- "<html>Replaying request in #{Fly.configuration.primary_region}</html>"
39
- end
40
-
10
+ module RegionalDatabase
41
11
  # Stop the current request and ask for it to be replayed in the primary region.
42
12
  # Pass one of three states to the target region, to determine how to handle the request:
43
13
  #
@@ -45,70 +15,84 @@ module Fly
45
15
  # captured_write: A write was rejected by the database
46
16
  # http_method: A non-idempotent HTTP method was replayed before hitting the application
47
17
  # threshold: A recent write set a threshold during which all requests are replayed
48
- #
49
- def replay_in_primary_region!(state:)
18
+
19
+ def self.replay_in_primary_region!(state:)
50
20
  res = Rack::Response.new(
51
- response_body,
21
+ "",
52
22
  409,
53
- {"fly-replay" => "region=#{Fly.configuration.primary_region};state=#{state}"}
23
+ {"Fly-Replay" => "region=#{Fly.configuration.primary_region};state=#{state}"}
54
24
  )
55
25
  res.finish
56
26
  end
57
27
 
58
- def within_replay_threshold?(threshold)
59
- threshold && (threshold.to_i - Time.now.to_i) > 0
60
- end
28
+ class DbExceptionHandlerMiddleware
29
+ def initialize(app)
30
+ @app = app
31
+ end
61
32
 
62
- def replayable_http_method?(http_method)
63
- Fly.configuration.replay_http_methods.include?(http_method)
33
+ def call(env)
34
+ exceptions = Fly.configuration.replayable_exception_classes
35
+ @app.call(env)
36
+ rescue *exceptions, ActiveRecord::RecordInvalid => e
37
+ if exceptions.any? {|ex| e.is_a?(ex) } || exceptions.any? { e&.cause&.is_a?(e) }
38
+ RegionalDatabase.replay_in_primary_region!(state: "captured_write")
39
+ else
40
+ raise e
41
+ end
42
+ end
64
43
  end
65
44
 
66
- def replay_request_state(header_value)
67
- header_value&.scan(/(.*?)=(.*?)($|;)/)&.detect { |v| v[0] == "state" }&.at(1)
68
- end
45
+ class ReplayableRequestMiddleware
46
+ def initialize(app)
47
+ @app = app
48
+ end
69
49
 
70
- def call(env)
71
- request = Rack::Request.new(env)
50
+ def within_replay_threshold?(threshold)
51
+ threshold && (threshold.to_i - Time.now.to_i) > 0
52
+ end
72
53
 
73
- # Check whether this request satisfies any of the following conditions for replaying in the primary region:
74
- #
75
- # 1. Its HTTP method matches those configured for automatic replay (post/patch/put/delete by default).
76
- # This approach should avoid potentially slow code execution - before_actions or other controller code -
77
- # happening before a request reaches a database write.
78
- # 2. It arrived before the threshold defined by the last write request. This threshold
79
- # helps avoid the same client from missing its own write due to replication lag,
80
- # like when a user adds to a todo list via XHR
81
-
82
- if !in_primary_region?
83
- if replayable_http_method?(request.request_method)
84
- return replay_in_primary_region!(state: "http_method")
85
- elsif within_replay_threshold?(request.cookies[Fly.configuration.replay_threshold_cookie])
86
- return replay_in_primary_region!(state: "threshold")
87
- end
54
+ def replayable_http_method?(http_method)
55
+ Fly.configuration.replay_http_methods.include?(http_method)
88
56
  end
89
57
 
90
- begin
91
- status, headers, body = @app.call(env)
92
- rescue ActiveRecord::StatementInvalid => e
93
- if e.cause.is_a?(PG::ReadOnlySqlTransaction)
94
- return replay_in_primary_region!(state: "captured_write")
95
- else
96
- raise e
97
- end
58
+ def replay_request_state(header_value)
59
+ header_value&.slice(/(?:^|;)state=([^;]*)/, 1)
98
60
  end
99
61
 
100
- response = Rack::Response.new(body, status, headers)
101
- replay_state = replay_request_state(request.get_header("HTTP_FLY_REPLAY_SRC"))
62
+ def call(env)
63
+ request = Rack::Request.new(env)
64
+
65
+ # Does this request satisfiy a condition for replaying in the primary region?
66
+ #
67
+ # 1. Its HTTP method matches those configured for automatic replay
68
+ # 2. It arrived before the threshold defined by the last write request.
69
+ # This threshold helps avoid the same client from missing its own
70
+ # write due to replication lag.
71
+
72
+ if Fly.configuration.in_secondary_region?
73
+ if replayable_http_method?(request.request_method)
74
+ return RegionalDatabase.replay_in_primary_region!(state: "http_method")
75
+ elsif within_replay_threshold?(request.cookies[Fly.configuration.replay_threshold_cookie])
76
+ return RegionalDatabase.replay_in_primary_region!(state: "threshold")
77
+ end
78
+ end
102
79
 
103
- # Request was replayed, but not by a threshold
104
- if replay_state && replay_state != "threshold"
105
- response.set_cookie(
106
- Fly.configuration.replay_threshold_cookie,
107
- Time.now.to_i + Fly.configuration.replay_threshold_in_seconds
108
- )
109
- end
80
+ status, headers, body = @app.call(env)
110
81
 
111
- response.finish
82
+ response = Rack::Response.new(body, status, headers)
83
+ replay_state = replay_request_state(request.get_header("HTTP_FLY_REPLAY_SRC"))
84
+
85
+ # Request was replayed, but not by a threshold, so set a threshold within which
86
+ # all requests should be replayed to the primary region
87
+ if replay_state && replay_state != "threshold"
88
+ response.set_cookie(
89
+ Fly.configuration.replay_threshold_cookie,
90
+ Time.now.to_i + Fly.configuration.replay_threshold_in_seconds
91
+ )
92
+ end
93
+
94
+ response.finish
95
+ end
112
96
  end
113
97
  end
114
98
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Fly
2
- VERSION = "0.2.1"
4
+ VERSION = "0.4.0"
3
5
  end
data/lib/fly-ruby.rb CHANGED
@@ -1,5 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "fly-ruby/configuration"
2
4
  require_relative "fly-ruby/regional_database"
5
+ require_relative "fly-ruby/headers"
6
+
3
7
  require "forwardable"
4
8
 
5
9
  if defined?(::Rails)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fly-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Sierles
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-07-15 00:00:00.000000000 Z
11
+ date: 2022-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -41,6 +41,7 @@ files:
41
41
  - fly-ruby.gemspec
42
42
  - lib/fly-ruby.rb
43
43
  - lib/fly-ruby/configuration.rb
44
+ - lib/fly-ruby/headers.rb
44
45
  - lib/fly-ruby/railtie.rb
45
46
  - lib/fly-ruby/regional_database.rb
46
47
  - lib/fly-ruby/version.rb
@@ -63,7 +64,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
63
64
  - !ruby/object:Gem::Version
64
65
  version: '0'
65
66
  requirements: []
66
- rubygems_version: 3.2.3
67
+ rubygems_version: 3.2.26
67
68
  signing_key:
68
69
  specification_version: 4
69
70
  summary: Augment Ruby web apps for deployment in Fly.io