active_record_proxy_adapters 0.4.6 → 0.6.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 +4 -4
- data/README.md +277 -7
- data/lib/active_record/connection_adapters/sqlite3_proxy_adapter.rb +42 -0
- data/lib/active_record/tasks/sqlite3_proxy_database_tasks.rb +19 -0
- data/lib/active_record_proxy_adapters/active_record_context.rb +2 -0
- data/lib/active_record_proxy_adapters/cache_configuration.rb +55 -0
- data/lib/active_record_proxy_adapters/configuration.rb +15 -9
- data/lib/active_record_proxy_adapters/connection_handling/sqlite3.rb +68 -0
- data/lib/active_record_proxy_adapters/connection_handling.rb +1 -0
- data/lib/active_record_proxy_adapters/primary_replica_proxy.rb +28 -11
- data/lib/active_record_proxy_adapters/railtie.rb +3 -0
- data/lib/active_record_proxy_adapters/sqlite3_proxy.rb +10 -0
- data/lib/active_record_proxy_adapters/synchronizable_configuration.rb +25 -0
- data/lib/active_record_proxy_adapters/version.rb +1 -1
- data/lib/active_record_proxy_adapters.rb +4 -0
- metadata +24 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7884786c370072543b8702a379b372ffa878e281e88e8e96ba603cec78a01523
|
4
|
+
data.tar.gz: 80141eaa2a1e1bbd9c4eb985136140acf006c16e5749497a114574f809325e98
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7f89bd7a1edf1e8813a9b70610c0153603958b64dbcecd5a00cc506301819e774b45ff291b06cfe01d8f25d2db6763853cb2bac4462d98836c33fbada30231b5
|
7
|
+
data.tar.gz: b7db70493adab591fd31c4294a630ff976e7cefac58a2e466f2e5cb6511102b1900054fbbc6d632a59cac8875f1f047d4ad0c78a5385f669987ec48bc8420651
|
data/README.md
CHANGED
@@ -40,9 +40,7 @@ Currently supported adapters:
|
|
40
40
|
- `postgresql`
|
41
41
|
- `mysql2`
|
42
42
|
- `trilogy`
|
43
|
-
|
44
|
-
Coming soon:
|
45
|
-
- `sqlite`
|
43
|
+
- `sqlite3`
|
46
44
|
|
47
45
|
|
48
46
|
#### PostgreSQL
|
@@ -73,6 +71,34 @@ development:
|
|
73
71
|
# your replica credentials here
|
74
72
|
```
|
75
73
|
|
74
|
+
#### Trilogy
|
75
|
+
```yaml
|
76
|
+
# config/database.yml
|
77
|
+
development:
|
78
|
+
primary:
|
79
|
+
adapter: trilogy_proxy
|
80
|
+
# your primary credentials here
|
81
|
+
|
82
|
+
primary_replica:
|
83
|
+
adapter: trilogy
|
84
|
+
replica: true
|
85
|
+
# your replica credentials here
|
86
|
+
```
|
87
|
+
|
88
|
+
#### SQLite
|
89
|
+
```yaml
|
90
|
+
# config/database.yml
|
91
|
+
development:
|
92
|
+
primary:
|
93
|
+
adapter: sqlite3_proxy
|
94
|
+
# your primary credentials here
|
95
|
+
|
96
|
+
primary_replica:
|
97
|
+
adapter: sqlite3
|
98
|
+
replica: true
|
99
|
+
# your replica credentials here
|
100
|
+
```
|
101
|
+
|
76
102
|
```ruby
|
77
103
|
# app/models/application_record.rb
|
78
104
|
class ApplicationRecord < ActiveRecord::Base
|
@@ -93,7 +119,7 @@ require "active_record_proxy_adapters/connection_handling"
|
|
93
119
|
class ApplicationRecord << ActiveRecord::Base
|
94
120
|
establish_connection(
|
95
121
|
{
|
96
|
-
adapter: 'postgresql_proxy',
|
122
|
+
adapter: 'postgresql_proxy', # or any of the following: mysql2_proxy, trilogy_proxy, sqlite3_proxy
|
97
123
|
# your primary credentials here
|
98
124
|
},
|
99
125
|
role: :writing
|
@@ -101,7 +127,7 @@ class ApplicationRecord << ActiveRecord::Base
|
|
101
127
|
|
102
128
|
establish_connection(
|
103
129
|
{
|
104
|
-
adapter: 'postgresql',
|
130
|
+
adapter: 'postgresql', # or any of the following: mysql2, trilogy, sqlite3
|
105
131
|
# your replica credentials here
|
106
132
|
},
|
107
133
|
role: :reading
|
@@ -195,7 +221,7 @@ production:
|
|
195
221
|
Then set `PRIMARY_DATABASE_ADAPTER=postgresql_proxy` to enable the proxy.
|
196
222
|
That way you can redeploy your application disabling the proxy completely, without any code change.
|
197
223
|
|
198
|
-
### Sticking to
|
224
|
+
### Sticking to a database manually
|
199
225
|
|
200
226
|
The proxy respects ActiveRecord's `#connected_to_stack` and will use it if present.
|
201
227
|
You can use that to force connection to the primary or replica and bypass the proxy entirely.
|
@@ -240,6 +266,84 @@ class SayHelloJob < ApplicationJob
|
|
240
266
|
end
|
241
267
|
```
|
242
268
|
|
269
|
+
## Caching Configuration
|
270
|
+
|
271
|
+
ActiveRecordProxyAdapters supports caching of SQL pattern matching results to improve performance for frequently executed queries.
|
272
|
+
|
273
|
+
### Enabling Caching
|
274
|
+
|
275
|
+
By default, caching is disabled (using `NullStore`). To enable caching:
|
276
|
+
|
277
|
+
```ruby
|
278
|
+
ActiveRecordProxyAdapters.configure do |config|
|
279
|
+
# Configure the cache store
|
280
|
+
config.cache do |cache|
|
281
|
+
# Use a specific cache implementation
|
282
|
+
# Notice that if using a Memcached or a Redis store, the network latency may outweigh the benefits you would get from caching the pattern matching
|
283
|
+
cache.store = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes)
|
284
|
+
|
285
|
+
# Optional: Customize the cache key prefix (default: "arpa_")
|
286
|
+
cache.key_prefix = "custom_prefix_"
|
287
|
+
|
288
|
+
# Optional: Customize the cache key generation (default: SHA2 hexdigest)
|
289
|
+
cache.key_builder = ->(sql) { "sql_#{Digest::MD5.hexdigest(sql)}" }
|
290
|
+
end
|
291
|
+
end
|
292
|
+
```
|
293
|
+
|
294
|
+
### How Caching Works
|
295
|
+
The caching system stores the results of SQL pattern matching operations to determine whether a query should be routed to a primary or replica database. This improves performance by avoiding repeated pattern matching on identical SQL strings.
|
296
|
+
|
297
|
+
- Cache keys are generated using the configured `key_builder` (SHA2 digest by default).
|
298
|
+
- All keys are prefixed with the configured `key_prefix` ("arpa_" by default).
|
299
|
+
- Cache misses are instrumented with the `active_record_proxy_adapters.cache_miss` notification. They can be monitored by subscribing to that topic:
|
300
|
+
```ruby
|
301
|
+
ActiveSupport::Notifications.subscribe("active_record_proxy_adapters.cache_miss") do |event|
|
302
|
+
cache_key, sql = event[:payload].values_at(:cache_key, :sql)
|
303
|
+
|
304
|
+
logger.info("Cache miss for SQL: #{sql.inspect} with cache key: #{cache_key.inspec}")
|
305
|
+
end
|
306
|
+
```
|
307
|
+
|
308
|
+
### Busting the cache
|
309
|
+
|
310
|
+
If you ever need to manually clear the cached SQL patterns:
|
311
|
+
|
312
|
+
```ruby
|
313
|
+
# This will clear all cached entries with the configured prefix
|
314
|
+
ActiveRecordProxyAdapters.bust_query_cache
|
315
|
+
```
|
316
|
+
|
317
|
+
### Performance Considerations
|
318
|
+
For applications with a high volume of repetitive queries, enabling caching can significantly reduce CPU overhead from SQL parsing. However, this comes with the tradeoff of increased memory usage in your cache store.
|
319
|
+
|
320
|
+
For optimal results:
|
321
|
+
- Consider enabling prepared statements as that will increase cache hit rate, and decrease cache growth rate
|
322
|
+
```ruby
|
323
|
+
irb(main):001> (1..10).each { |i| User.where(id: i).exists? }
|
324
|
+
```
|
325
|
+
_Without_ Prepared statements yields
|
326
|
+
```
|
327
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = 1 LIMIT 1" with cache key: "arpa_9fa3972e45b27985eef6bfb4aa6269c12d43363c60e7aa67fb290ec317503710"
|
328
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = 2 LIMIT 1" with cache key: "arpa_0e51756270138442ad26087dffcfb53c21df4a430961f1ca3b4270183f4b066d"
|
329
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = 3 LIMIT 1" with cache key: "arpa_db5b8c323ee2c284ba96adc6e20b7ea1373ca07fa9b09969f5207d467bd895b6"
|
330
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = 4 LIMIT 1" with cache key: "arpa_129459a1ba342cad3dbd4458cd8eacda4ed641a94a5d1e6cc23604495e44b565"
|
331
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = 5 LIMIT 1" with cache key: "arpa_9817a74a6f162ea110ed14cef79e95aa78830ff19266cdce75668e0c9c5ccef7"
|
332
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = 6 LIMIT 1" with cache key: "arpa_610e37a117abc81ec1afebafa0f36b35547f57879536ca7535475075ea08d8ac"
|
333
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = 7 LIMIT 1" with cache key: "arpa_79e172d168c59c4e5befbe954861ff9076000f955719dd3cca1423b68fb5f319"
|
334
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = 8 LIMIT 1" with cache key: "arpa_fb29367bdae3e2a48d1fa63cca00fd611c0b6dc84c9f5fd985b9222d49f1f7d9"
|
335
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = 9 LIMIT 1" with cache key: "arpa_e6e9a73cf9066077893b21dde038e5a616bf25731aeb5a4a9cdb41b7d84d1ece"
|
336
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = 10 LIMIT 1" with cache key: "arpa_8c94cdae65b6d529364d6ae8cf68f0e827566d471d2bd1107d6bdca29345759e"
|
337
|
+
```
|
338
|
+
|
339
|
+
_With_ prepared statments yields
|
340
|
+
```
|
341
|
+
Cache miss for SQL: "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"id\" = $1 LIMIT $2" with cache key: "arpa_3c2ef2bb9a5f370adf63eac3bc9994c054554798d31d247818049c8c21cb68be"
|
342
|
+
```
|
343
|
+
- Use a cache store with an appropriate size limit, and low latency (Memory Store has lower latency than Memcached or Redis)
|
344
|
+
- Monitor cache hit/miss rates using the instrumentation events
|
345
|
+
- Consider occasionally busting the cache during low-traffic periods to prevent stale entries, or setting a reasonable expiry window for cached values
|
346
|
+
|
243
347
|
### Thread safety
|
244
348
|
|
245
349
|
Since Rails already leases exactly one connection per thread from the pool and the adapter operates on that premise, it is safe to use it in multi-threaded servers such as Puma.
|
@@ -337,7 +441,173 @@ irb(main):051:0> test_multithread_queries
|
|
337
441
|
|
338
442
|
## Building your own proxy
|
339
443
|
|
340
|
-
|
444
|
+
These instructions assume an active record adapter `ActiveRecord::ConnectionAdapters::FoobarAdapter` already exists and is properly loaded in your environment.
|
445
|
+
|
446
|
+
To create a proxy adapter for an existing database `FoobarAdapter`, follow these steps under the lib folder of your rails application source code:
|
447
|
+
|
448
|
+
1. **Create database tasks for your proxy adapter** to allow Rails tasks like `db:create` and `db:migrate` to work:
|
449
|
+
|
450
|
+
```ruby
|
451
|
+
# lib/active_record/tasks/foobar_proxy_database_tasks.rb
|
452
|
+
|
453
|
+
require "active_record_proxy_adapters/database_tasks"
|
454
|
+
|
455
|
+
module ActiveRecord
|
456
|
+
module Tasks
|
457
|
+
class FoobarProxyDatabaseTasks < FoobarDatabaseTasks
|
458
|
+
include ActiveRecordProxyAdapters::DatabaseTasks
|
459
|
+
end
|
460
|
+
end
|
461
|
+
end
|
462
|
+
|
463
|
+
ActiveRecord::Tasks::DatabaseTasks.register_task(
|
464
|
+
/foobar_proxy/,
|
465
|
+
"ActiveRecord::Tasks::FoobarProxyDatabaseTasks"
|
466
|
+
)
|
467
|
+
```
|
468
|
+
|
469
|
+
2. **Create the proxy implementation class** that will handle the routing logic:
|
470
|
+
|
471
|
+
```ruby
|
472
|
+
# lib/active_record_proxy_adapters/foobar_proxy.rb
|
473
|
+
|
474
|
+
require "active_record_proxy_adapters/primary_replica_proxy"
|
475
|
+
|
476
|
+
module ActiveRecordProxyAdapters
|
477
|
+
class FoobarProxy < PrimaryReplicaProxy
|
478
|
+
# Override or hijack extra methods here if you need custom behavior
|
479
|
+
# For most adapters, the default behavior works fine
|
480
|
+
end
|
481
|
+
end
|
482
|
+
```
|
483
|
+
|
484
|
+
3. **Create the proxy adapter class** that inherits from the underlying adapter, including the `Hijackable` concern. You need to require the database tasks source, the original adapter source, and the proxy source:
|
485
|
+
|
486
|
+
```ruby
|
487
|
+
# lib/active_record/connection_adapters/foobar_proxy_adapter.rb
|
488
|
+
|
489
|
+
require "active_record/tasks/foobar_proxy_database_tasks"
|
490
|
+
require "active_record/connection_adapters/foobar_adapter"
|
491
|
+
require "active_record_proxy_adapters/active_record_context"
|
492
|
+
require "active_record_proxy_adapters/hijackable"
|
493
|
+
require "active_record_proxy_adapters/foobar_proxy"
|
494
|
+
|
495
|
+
module ActiveRecord
|
496
|
+
module ConnectionAdapters
|
497
|
+
class FoobarProxyAdapter < FoobarAdapter
|
498
|
+
include ActiveRecordProxyAdapters::Hijackable
|
499
|
+
|
500
|
+
ADAPTER_NAME = "FoobarProxy" # This is only an ActiveRecord convention and is not required to work
|
501
|
+
|
502
|
+
delegate_to_proxy(*ActiveRecordProxyAdapters::ActiveRecordContext.hijackable_methods)
|
503
|
+
|
504
|
+
def initialize(...)
|
505
|
+
@proxy = ActiveRecordProxyAdapters::FoobarProxy.new(self)
|
506
|
+
|
507
|
+
super
|
508
|
+
end
|
509
|
+
|
510
|
+
private
|
511
|
+
|
512
|
+
attr_reader :proxy
|
513
|
+
end
|
514
|
+
end
|
515
|
+
end
|
516
|
+
|
517
|
+
# This is only required for Rails 7.2 or greater.
|
518
|
+
if ActiveRecordProxyAdapters::ActiveRecordContext.active_record_v7_2_or_greater?
|
519
|
+
ActiveRecord::ConnectionAdapters.register(
|
520
|
+
"foobar_proxy",
|
521
|
+
"ActiveRecord::ConnectionAdapters::FoobarProxyAdapter",
|
522
|
+
"active_record/connection_adapters/foobar_proxy_adapter"
|
523
|
+
)
|
524
|
+
end
|
525
|
+
|
526
|
+
ActiveSupport.run_load_hooks(:active_record_foobarproxyadapter,
|
527
|
+
ActiveRecord::ConnectionAdapters::FoobarProxyAdapter)
|
528
|
+
```
|
529
|
+
|
530
|
+
4. **Create connection handling module** for ActiveRecord integration:
|
531
|
+
|
532
|
+
```ruby
|
533
|
+
# lib/active_record_proxy_adapters/connection_handling/foobar.rb
|
534
|
+
|
535
|
+
begin
|
536
|
+
require "active_record/connection_adapters/foobar_proxy_adapter"
|
537
|
+
rescue LoadError
|
538
|
+
# foobar not available
|
539
|
+
return
|
540
|
+
end
|
541
|
+
|
542
|
+
# This is only required for Rails 7.0 or earlier.
|
543
|
+
module ActiveRecordProxyAdapters
|
544
|
+
module Foobar
|
545
|
+
module ConnectionHandling
|
546
|
+
def foobar_proxy_adapter_class
|
547
|
+
ActiveRecord::ConnectionAdapters::FoobarProxyAdapter
|
548
|
+
end
|
549
|
+
|
550
|
+
def foobar_proxy_connection(config)
|
551
|
+
# copy and paste the contents of the original method foobar_connection here.
|
552
|
+
# If the contents contain a hardcoded FooBarAdapter.new instance,
|
553
|
+
# replace it with foobar_proxy_adapter_class.new
|
554
|
+
end
|
555
|
+
end
|
556
|
+
end
|
557
|
+
end
|
558
|
+
|
559
|
+
ActiveSupport.on_load(:active_record) do
|
560
|
+
ActiveRecord::Base.extend(ActiveRecordProxyAdapters::Foobar::ConnectionHandling)
|
561
|
+
end
|
562
|
+
```
|
563
|
+
|
564
|
+
5. **In your initializer, load the custom adapter** when the parent adapter is fully loaded:
|
565
|
+
|
566
|
+
```ruby
|
567
|
+
# config/initializers/active_record_proxy_adapters.rb
|
568
|
+
|
569
|
+
# The parent adapter should have a load hook already. If not, you might need to monkey patch it.
|
570
|
+
ActiveSupport.on_load(:active_record_foobaradapter) do
|
571
|
+
require "active_record_proxy_adapters/connection_handling/foobar"
|
572
|
+
end
|
573
|
+
```
|
574
|
+
|
575
|
+
6. **Add a custom Zeitwerk inflection rule** if your adapter file paths do not follow Rails conventions. You can skip this if it does:
|
576
|
+
|
577
|
+
```ruby
|
578
|
+
# config/initializers/active_record_proxy_adapters.rb
|
579
|
+
|
580
|
+
Rails.autoloaders.each do |autoloader|
|
581
|
+
autoloader.inflector.inflect(
|
582
|
+
"foobar_proxy_adapter" => "FoobarProxyAdapter"
|
583
|
+
)
|
584
|
+
end
|
585
|
+
```
|
586
|
+
|
587
|
+
7. **Configure your database.yml** to use your new adapter:
|
588
|
+
|
589
|
+
```yaml
|
590
|
+
development:
|
591
|
+
primary:
|
592
|
+
adapter: foobar_proxy
|
593
|
+
# primary database configuration
|
594
|
+
|
595
|
+
primary_replica:
|
596
|
+
adapter: foobar
|
597
|
+
replica: true
|
598
|
+
# replica database configuration
|
599
|
+
```
|
600
|
+
|
601
|
+
8. **Set up your model to use both connections**:
|
602
|
+
|
603
|
+
```ruby
|
604
|
+
class ApplicationRecord < ActiveRecord::Base
|
605
|
+
self.abstract_class = true
|
606
|
+
connects_to database: { writing: :primary, reading: :primary_replica }
|
607
|
+
end
|
608
|
+
```
|
609
|
+
|
610
|
+
For testing your adapter, follow the examples in the test suite by creating spec files that match the pattern used for the other adapters.
|
341
611
|
|
342
612
|
## Development
|
343
613
|
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record/tasks/sqlite3_proxy_database_tasks"
|
4
|
+
require "active_record/connection_adapters/sqlite3_adapter"
|
5
|
+
require "active_record_proxy_adapters/active_record_context"
|
6
|
+
require "active_record_proxy_adapters/hijackable"
|
7
|
+
require "active_record_proxy_adapters/sqlite3_proxy"
|
8
|
+
|
9
|
+
module ActiveRecord
|
10
|
+
module ConnectionAdapters
|
11
|
+
# This adapter is a proxy to the original SQLite3Adapter, allowing the use of the
|
12
|
+
# ActiveRecordProxyAdapters::PrimaryReplicaProxy.
|
13
|
+
class SQLite3ProxyAdapter < SQLite3Adapter
|
14
|
+
include ActiveRecordProxyAdapters::Hijackable
|
15
|
+
|
16
|
+
ADAPTER_NAME = "SQLite3Proxy"
|
17
|
+
|
18
|
+
delegate_to_proxy(*ActiveRecordProxyAdapters::ActiveRecordContext.hijackable_methods)
|
19
|
+
|
20
|
+
def initialize(...)
|
21
|
+
@proxy = ActiveRecordProxyAdapters::SQLite3Proxy.new(self)
|
22
|
+
|
23
|
+
super
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
attr_reader :proxy
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
if ActiveRecordProxyAdapters::ActiveRecordContext.active_record_v7_2_or_greater?
|
34
|
+
ActiveRecord::ConnectionAdapters.register(
|
35
|
+
"sqlite3_proxy",
|
36
|
+
"ActiveRecord::ConnectionAdapters::SQLite3ProxyAdapter",
|
37
|
+
"active_record/connection_adapters/sqlite3_proxy_adapter"
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
ActiveSupport.run_load_hooks(:active_record_sqlite3proxyadapter,
|
42
|
+
ActiveRecord::ConnectionAdapters::SQLite3ProxyAdapter)
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_proxy_adapters/database_tasks"
|
4
|
+
|
5
|
+
module ActiveRecord
|
6
|
+
module Tasks
|
7
|
+
# Defines the sqlite3 tasks for dropping, creating, loading schema and dumping schema.
|
8
|
+
# Bypasses all the proxy logic to send all requests to primary.
|
9
|
+
class SQLite3ProxyDatabaseTasks < SQLiteDatabaseTasks
|
10
|
+
include ActiveRecordProxyAdapters::DatabaseTasks
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Allow proxy adapter to run rake tasks, i.e. db:drop, db:create, db:schema:load db:migrate, etc...
|
16
|
+
ActiveRecord::Tasks::DatabaseTasks.register_task(
|
17
|
+
/sqlite3_proxy/,
|
18
|
+
"ActiveRecord::Tasks::SQLite3ProxyDatabaseTasks"
|
19
|
+
)
|
@@ -12,6 +12,7 @@ module ActiveRecordProxyAdapters
|
|
12
12
|
delegate_missing_to :new
|
13
13
|
end
|
14
14
|
|
15
|
+
# rubocop:disable Naming/PredicateMethod
|
15
16
|
NullConnectionHandlingContext = Class.new do
|
16
17
|
def legacy_connection_handling
|
17
18
|
false
|
@@ -21,6 +22,7 @@ module ActiveRecordProxyAdapters
|
|
21
22
|
nil
|
22
23
|
end
|
23
24
|
end
|
25
|
+
# rubocop:enable Naming/PredicateMethod
|
24
26
|
|
25
27
|
def connection_class_for(connection)
|
26
28
|
return connection.connection_descriptor.name.constantize if active_record_v8_0_2_or_greater?
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_proxy_adapters/synchronizable_configuration"
|
4
|
+
|
5
|
+
module ActiveRecordProxyAdapters
|
6
|
+
class CacheConfiguration # rubocop:disable Style/Documentation
|
7
|
+
include SynchronizableConfiguration
|
8
|
+
|
9
|
+
# Sets the cache store to use for caching SQL statements.
|
10
|
+
#
|
11
|
+
# @param store [ActiveSupport::Cache::Store] The cache store to use for caching SQL statements.
|
12
|
+
# Defaults to ActiveSupport::Cache::NullStore, which does not cache anything.
|
13
|
+
# Thread safe.
|
14
|
+
# @return [ActiveSupport::Cache::Store] The cache store to use for caching SQL statements.
|
15
|
+
# Defaults to ActiveSupport::Cache::NullStore, which does not cache anything.
|
16
|
+
# Thread safe.
|
17
|
+
attr_reader :store
|
18
|
+
|
19
|
+
# @return [String] The prefix to use for cache keys. Defaults to "arpa_".
|
20
|
+
attr_reader :key_prefix
|
21
|
+
|
22
|
+
# @return [Proc] A proc that takes an SQL statement and returns a cache key.
|
23
|
+
# Defaults to a SHA2 hexdigest of the SQL statement.
|
24
|
+
attr_reader :key_builder
|
25
|
+
|
26
|
+
def initialize(lock = Monitor.new)
|
27
|
+
@lock = lock
|
28
|
+
self.store = ActiveSupport::Cache::NullStore.new
|
29
|
+
self.key_prefix = "arpa_"
|
30
|
+
self.key_builder = ->(sql) { Digest::SHA2.hexdigest(sql) }
|
31
|
+
end
|
32
|
+
|
33
|
+
def store=(store)
|
34
|
+
synchronize_update(:"cache.store", from: @store, to: store) do
|
35
|
+
@store = store
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def key_prefix=(key_prefix)
|
40
|
+
synchronize_update(:"cache.key_prefix", from: @key_prefix, to: key_prefix) do
|
41
|
+
@key_prefix = key_prefix
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def key_builder=(key_builder)
|
46
|
+
synchronize_update(:"cache.key_builder", from: @key_builder, to: key_builder) do
|
47
|
+
@key_builder = key_builder
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def bust
|
52
|
+
store.delete_matched("#{key_prefix}*")
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -1,10 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "active_support/core_ext/integer/time"
|
4
|
+
require "active_record_proxy_adapters/synchronizable_configuration"
|
5
|
+
require "active_record_proxy_adapters/cache_configuration"
|
4
6
|
|
5
7
|
module ActiveRecordProxyAdapters
|
6
8
|
# Provides a global configuration object to configure how the proxy should behave.
|
7
9
|
class Configuration
|
10
|
+
include SynchronizableConfiguration
|
11
|
+
|
8
12
|
PROXY_DELAY = 2.seconds.freeze
|
9
13
|
CHECKOUT_TIMEOUT = 2.seconds.freeze
|
10
14
|
LOG_SUBSCRIBER_PRIMARY_PREFIX = proc { |event| "#{event.payload[:connection].class::ADAPTER_NAME} Primary" }.freeze
|
@@ -30,6 +34,7 @@ module ActiveRecordProxyAdapters
|
|
30
34
|
self.checkout_timeout = CHECKOUT_TIMEOUT
|
31
35
|
self.log_subscriber_primary_prefix = LOG_SUBSCRIBER_PRIMARY_PREFIX
|
32
36
|
self.log_subscriber_replica_prefix = LOG_SUBSCRIBER_REPLICA_PREFIX
|
37
|
+
self.cache_configuration = CacheConfiguration.new(@lock)
|
33
38
|
end
|
34
39
|
|
35
40
|
def log_subscriber_primary_prefix=(prefix)
|
@@ -60,17 +65,18 @@ module ActiveRecordProxyAdapters
|
|
60
65
|
end
|
61
66
|
end
|
62
67
|
|
68
|
+
def cache
|
69
|
+
block_given? ? yield(cache_configuration) : cache_configuration
|
70
|
+
end
|
71
|
+
|
63
72
|
private
|
64
73
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
to:
|
72
|
-
) do
|
73
|
-
@lock.synchronize(&block)
|
74
|
+
# @return [CacheConfiguration] The cache configuration for the proxy adapters.
|
75
|
+
attr_reader :cache_configuration
|
76
|
+
|
77
|
+
def cache_configuration=(cache_configuration)
|
78
|
+
synchronize_update(:cache_configuration, from: @cache_configuration, to: cache_configuration) do
|
79
|
+
@cache_configuration = cache_configuration
|
74
80
|
end
|
75
81
|
end
|
76
82
|
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require "active_record/connection_adapters/sqlite3_proxy_adapter"
|
5
|
+
rescue LoadError
|
6
|
+
# sqlite3 not available
|
7
|
+
return
|
8
|
+
end
|
9
|
+
|
10
|
+
module ActiveRecordProxyAdapters
|
11
|
+
module SQLite3
|
12
|
+
# Module to extend ActiveRecord::Base with the connection handling methods.
|
13
|
+
# Required to make adapter work in ActiveRecord versions <= 7.2.x
|
14
|
+
module ConnectionHandling
|
15
|
+
def sqlite3_proxy_adapter_class
|
16
|
+
ActiveRecord::ConnectionAdapters::SQLite3ProxyAdapter
|
17
|
+
end
|
18
|
+
|
19
|
+
def sqlite3_proxy_connection(config)
|
20
|
+
connection_factory_mapping
|
21
|
+
.fetch("#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}")
|
22
|
+
.call(config)
|
23
|
+
end
|
24
|
+
|
25
|
+
def connection_factory_mapping
|
26
|
+
{
|
27
|
+
"7.0" => ->(config) { sqlite3_proxy_connection_ar_v70(config) },
|
28
|
+
"7.1" => ->(config) { sqlite3_proxy_connection_ar_v71(config) }
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
def sqlite3_proxy_connection_ar_v70(config) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength
|
33
|
+
config = config.symbolize_keys
|
34
|
+
|
35
|
+
# Require database.
|
36
|
+
raise ArgumentError, "No database file specified. Missing argument: database" unless config[:database]
|
37
|
+
|
38
|
+
# Allow database path relative to Rails.root, but only if the database
|
39
|
+
# path is not the special path that tells sqlite to build a database only
|
40
|
+
# in memory.
|
41
|
+
if ":memory:" != config[:database] && !config[:database].to_s.start_with?("file:") # rubocop:disable Style/YodaCondition
|
42
|
+
config[:database] = File.expand_path(config[:database], Rails.root) if defined?(Rails.root)
|
43
|
+
dirname = File.dirname(config[:database])
|
44
|
+
Dir.mkdir(dirname) unless File.directory?(dirname)
|
45
|
+
end
|
46
|
+
|
47
|
+
db = ::SQLite3::Database.new(
|
48
|
+
config[:database].to_s,
|
49
|
+
config.merge(results_as_hash: true)
|
50
|
+
)
|
51
|
+
|
52
|
+
sqlite3_proxy_adapter_class.new(db, logger, nil, config)
|
53
|
+
rescue Errno::ENOENT => e
|
54
|
+
raise ActiveRecord::NoDatabaseError if e.message.include?("No such file or directory")
|
55
|
+
|
56
|
+
raise
|
57
|
+
end
|
58
|
+
|
59
|
+
def sqlite3_proxy_connection_ar_v71(config)
|
60
|
+
sqlite3_proxy_adapter_class.new(config)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
ActiveSupport.on_load(:active_record) do
|
67
|
+
ActiveRecord::Base.extend(ActiveRecordProxyAdapters::SQLite3::ConnectionHandling)
|
68
|
+
end
|
@@ -3,3 +3,4 @@
|
|
3
3
|
require "active_record_proxy_adapters/connection_handling/postgresql"
|
4
4
|
require "active_record_proxy_adapters/connection_handling/mysql2"
|
5
5
|
require "active_record_proxy_adapters/connection_handling/trilogy"
|
6
|
+
require "active_record_proxy_adapters/connection_handling/sqlite3"
|
@@ -110,15 +110,22 @@ module ActiveRecordProxyAdapters
|
|
110
110
|
end.last
|
111
111
|
end
|
112
112
|
|
113
|
-
def roles_for(sql_string)
|
113
|
+
def roles_for(sql_string) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
114
114
|
return [top_of_connection_stack_role] if top_of_connection_stack_role.present?
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
115
|
+
return [writing_role] if recent_write_to_primary? || in_transaction?
|
116
|
+
|
117
|
+
cache_key = cache_key_for(sql_string)
|
118
|
+
cache_store.fetch(cache_key) do
|
119
|
+
ActiveSupport::Notifications.instrument("active_record_proxy_adapters.cache_miss",
|
120
|
+
cache_key: cache_key, sql: sql_string) do
|
121
|
+
if need_all?(sql_string)
|
122
|
+
[reading_role, writing_role]
|
123
|
+
elsif need_primary?(sql_string)
|
124
|
+
[writing_role]
|
125
|
+
else
|
126
|
+
[reading_role]
|
127
|
+
end
|
128
|
+
end
|
122
129
|
end
|
123
130
|
end
|
124
131
|
|
@@ -132,6 +139,10 @@ module ActiveRecordProxyAdapters
|
|
132
139
|
[reading_role, writing_role].include?(role) ? role : nil
|
133
140
|
end
|
134
141
|
|
142
|
+
def cache_key_for(sql_string)
|
143
|
+
cache_config.key_builder.call(sql_string).prepend(cache_config.key_prefix)
|
144
|
+
end
|
145
|
+
|
135
146
|
def connected_to_stack
|
136
147
|
return connection_class.connected_to_stack if connection_class.respond_to?(:connected_to_stack)
|
137
148
|
|
@@ -182,9 +193,7 @@ module ActiveRecordProxyAdapters
|
|
182
193
|
# @return [TrueClass] if there has been a write within the last {#proxy_delay} seconds
|
183
194
|
# @return [TrueClass] if sql_string matches a write statement (i.e. INSERT, UPDATE, DELETE, SELECT FOR UPDATE)
|
184
195
|
# @return [FalseClass] if sql_string matches a read statement (i.e. SELECT)
|
185
|
-
def need_primary?(sql_string)
|
186
|
-
return true if recent_write_to_primary?
|
187
|
-
return true if in_transaction?
|
196
|
+
def need_primary?(sql_string)
|
188
197
|
return true if cte_for_write?(sql_string)
|
189
198
|
return true if SQL_PRIMARY_MATCHERS.any?(&match_sql?(sql_string))
|
190
199
|
return false if SQL_REPLICA_MATCHERS.any?(&match_sql?(sql_string))
|
@@ -226,6 +235,14 @@ module ActiveRecordProxyAdapters
|
|
226
235
|
@last_write_at = Concurrent.monotonic_time
|
227
236
|
end
|
228
237
|
|
238
|
+
def cache_store
|
239
|
+
cache_config.store
|
240
|
+
end
|
241
|
+
|
242
|
+
def cache_config
|
243
|
+
proxy_config.cache
|
244
|
+
end
|
245
|
+
|
229
246
|
def proxy_delay
|
230
247
|
proxy_config.proxy_delay
|
231
248
|
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_proxy_adapters/primary_replica_proxy"
|
4
|
+
require "active_record_proxy_adapters/active_record_context"
|
5
|
+
|
6
|
+
module ActiveRecordProxyAdapters
|
7
|
+
# Proxy to the original SQLite3Adapter, allowing the use of the ActiveRecordProxyAdapters::PrimaryReplicaProxy.
|
8
|
+
class SQLite3Proxy < PrimaryReplicaProxy
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordProxyAdapters
|
4
|
+
module SynchronizableConfiguration # rubocop:disable Style/Documentation
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
private
|
9
|
+
|
10
|
+
attr_reader :lock
|
11
|
+
|
12
|
+
def synchronize_update(attribute, from:, to:, &block)
|
13
|
+
ActiveSupport::Notifications.instrument(
|
14
|
+
"active_record_proxy_adapters.configuration_update",
|
15
|
+
attribute:,
|
16
|
+
who: Thread.current,
|
17
|
+
from:,
|
18
|
+
to:
|
19
|
+
) do
|
20
|
+
lock.synchronize(&block)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_record_proxy_adapters
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.6.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Matt Cruz
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date:
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: activerecord
|
@@ -49,6 +49,20 @@ dependencies:
|
|
49
49
|
- - "<"
|
50
50
|
- !ruby/object:Gem::Version
|
51
51
|
version: '8.1'
|
52
|
+
- !ruby/object:Gem::Dependency
|
53
|
+
name: digest
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: 3.1.0
|
59
|
+
type: :runtime
|
60
|
+
prerelease: false
|
61
|
+
version_requirements: !ruby/object:Gem::Requirement
|
62
|
+
requirements:
|
63
|
+
- - ">="
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: 3.1.0
|
52
66
|
description: |-
|
53
67
|
This gem allows automatic connection switching between a primary and one read replica database in ActiveRecord.
|
54
68
|
It pattern matches the SQL statement being sent to decide whether it should go to the replica (SELECT) or the
|
@@ -58,23 +72,27 @@ email:
|
|
58
72
|
executables: []
|
59
73
|
extensions: []
|
60
74
|
extra_rdoc_files:
|
61
|
-
- README.md
|
62
75
|
- LICENSE.txt
|
76
|
+
- README.md
|
63
77
|
files:
|
64
78
|
- LICENSE.txt
|
65
79
|
- README.md
|
66
80
|
- lib/active_record/connection_adapters/mysql2_proxy_adapter.rb
|
67
81
|
- lib/active_record/connection_adapters/postgresql_proxy_adapter.rb
|
82
|
+
- lib/active_record/connection_adapters/sqlite3_proxy_adapter.rb
|
68
83
|
- lib/active_record/connection_adapters/trilogy_proxy_adapter.rb
|
69
84
|
- lib/active_record/tasks/mysql2_proxy_database_tasks.rb
|
70
85
|
- lib/active_record/tasks/postgresql_proxy_database_tasks.rb
|
86
|
+
- lib/active_record/tasks/sqlite3_proxy_database_tasks.rb
|
71
87
|
- lib/active_record/tasks/trilogy_proxy_database_tasks.rb
|
72
88
|
- lib/active_record_proxy_adapters.rb
|
73
89
|
- lib/active_record_proxy_adapters/active_record_context.rb
|
90
|
+
- lib/active_record_proxy_adapters/cache_configuration.rb
|
74
91
|
- lib/active_record_proxy_adapters/configuration.rb
|
75
92
|
- lib/active_record_proxy_adapters/connection_handling.rb
|
76
93
|
- lib/active_record_proxy_adapters/connection_handling/mysql2.rb
|
77
94
|
- lib/active_record_proxy_adapters/connection_handling/postgresql.rb
|
95
|
+
- lib/active_record_proxy_adapters/connection_handling/sqlite3.rb
|
78
96
|
- lib/active_record_proxy_adapters/connection_handling/trilogy.rb
|
79
97
|
- lib/active_record_proxy_adapters/database_tasks.rb
|
80
98
|
- lib/active_record_proxy_adapters/hijackable.rb
|
@@ -83,6 +101,8 @@ files:
|
|
83
101
|
- lib/active_record_proxy_adapters/postgresql_proxy.rb
|
84
102
|
- lib/active_record_proxy_adapters/primary_replica_proxy.rb
|
85
103
|
- lib/active_record_proxy_adapters/railtie.rb
|
104
|
+
- lib/active_record_proxy_adapters/sqlite3_proxy.rb
|
105
|
+
- lib/active_record_proxy_adapters/synchronizable_configuration.rb
|
86
106
|
- lib/active_record_proxy_adapters/trilogy_proxy.rb
|
87
107
|
- lib/active_record_proxy_adapters/version.rb
|
88
108
|
homepage: https://github.com/Nasdaq/active_record_proxy_adapters
|
@@ -108,7 +128,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
108
128
|
- !ruby/object:Gem::Version
|
109
129
|
version: '0'
|
110
130
|
requirements: []
|
111
|
-
rubygems_version: 3.6.
|
131
|
+
rubygems_version: 3.6.9
|
112
132
|
specification_version: 4
|
113
133
|
summary: Read replica proxy adapters for ActiveRecord!
|
114
134
|
test_files: []
|