fly-ruby 0.2.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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