bouncy 0.2.1 → 0.2.2
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/CHANGELOG.md +9 -0
- data/README.md +1 -1
- data/guides/compatibility.md +8 -2
- data/lib/bouncy/address_lock.rb +5 -6
- data/lib/bouncy/database.rb +33 -0
- data/lib/bouncy/scope_lock.rb +4 -6
- data/lib/bouncy/version.rb +1 -1
- data/lib/bouncy.rb +2 -1
- data/lib/generators/bouncy/templates/create_bouncy_tables.rb.erb +18 -4
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 454c9ceb5482a94e7885dd6d648b9716e05d24c6ffe6d11cae8e4957297be373
|
|
4
|
+
data.tar.gz: 36c35cc2d4b7b39d63722dd04cdb36a773cd0814dca1b7f8fd12be463ac55222
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ab0266ad476a1b157171125d6062c3eda54b02b078aaaa244e6e22dee750c7ec19512cd36bec481270a8debe5b578b6502d3267bc01375450ec07990c33398e
|
|
7
|
+
data.tar.gz: '088f91735a153cdf3c6f9c922dbc775641743d375b11733f98438403d50ff0afbfa064e136486a416ccbcad97dc7e5e9887b134a229eb3caa860112f8f4151bc'
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.2] - 2026-10-26
|
|
6
|
+
|
|
7
|
+
One fix that an app on the fleet found with the PostGIS adapter. There is no new migration.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Bouncy knows a database by its Active Record adapter class, so an adapter that subclasses a Rails adapter gets the locks of that database whatever name it reports, because the PostGIS adapter (`activerecord-postgis-adapter`) is a `PostgreSQLAdapter` whose `adapter_name` is `"PostGIS"`, and Bouncy matched the name `"PostgreSQL"` exactly: it raised "Bouncy supports PostgreSQL, MySQL and SQLite", the receiver answered 503 to every signed SNS message, and `Bouncy.hold`, `Bouncy.sync!` and every other write raised. The name is still the fallback for an adapter that only renames itself. One helper, `Bouncy::Database`, now makes this choice for every lock and read.
|
|
12
|
+
- The install migration gives a PostGIS database JSONB columns and native UUID keys, as it gives PostgreSQL, because it looked for `"postgres"` in the adapter name and made JSON columns and 36-character string keys there. Tables made that way keep working and need no change.
|
|
13
|
+
|
|
5
14
|
## [0.2.1] - 2026-10-25
|
|
6
15
|
|
|
7
16
|
Three gaps that the first app to move its own suppression table to Bouncy found. There is no new migration.
|
data/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Check out my other 💎 Ruby gems: [`nondisposable`](https://github.com/rameerez
|
|
|
41
41
|
## Requirements
|
|
42
42
|
|
|
43
43
|
- Ruby **3.3+** and Rails **7.2.3.2+**, below Rails 9. Tested on Ruby 3.3/3.4/4.0 and Rails 7.2/8.0/8.1.
|
|
44
|
-
- **PostgreSQL, MySQL or SQLite**, with bigint or UUID primary keys.
|
|
44
|
+
- **PostgreSQL** (including the PostGIS adapter), **MySQL** or **SQLite**, with bigint or UUID primary keys.
|
|
45
45
|
- **Amazon SES**, one account and region. SES SMTP is supported; suppression management needs AWS API credentials.
|
|
46
46
|
|
|
47
47
|
Start in observation mode and verify your sending setup before enabling dropping. See [compatibility and validation](guides/compatibility.md) for the tested matrix and remaining validation limits.
|
data/guides/compatibility.md
CHANGED
|
@@ -6,6 +6,12 @@ The v0.1.0 release run on September 9, 2026 passed all 54 combinations using Rub
|
|
|
6
6
|
|
|
7
7
|
The v0.2.0 local run on October 25, 2026 passed all 54 combinations with the same Ruby, Rails, PostgreSQL and MySQL versions and SQLite 3.54.1, and the same 18 again with Rails 8.0.2, the oldest 8.0 release the transactional-fixture support was checked on: 201 tests on SQLite (one skipped there: a second pool cannot share an in-memory database), 220 on PostgreSQL and 219 on MySQL with Rails 8.1, one fewer with Rails 7.2 and 8.0, and at least 96.5% line and 91.5% branch coverage. It adds the address-lock tests with real connections on PostgreSQL and MySQL. No live SES run was part of it.
|
|
8
8
|
|
|
9
|
+
The v0.2.2 local run on October 26, 2026 used Ruby 3.3.5 and Rails 8.1 only: 236 tests on SQLite (one skipped, as before), 256 on PostgreSQL and 255 on MySQL, all passing. It adds tests for adapters that subclass a Rails adapter under another name, with the suite's own connection renamed `"PostGIS"` on PostgreSQL. No live SES run and no run on the PostGIS adapter itself was part of it.
|
|
10
|
+
|
|
11
|
+
## Database adapters
|
|
12
|
+
|
|
13
|
+
Bouncy knows a database by its Active Record adapter class. An adapter that subclasses `PostgreSQLAdapter`, `AbstractMysqlAdapter` or `SQLite3Adapter` belongs to that database whatever `adapter_name` it reports. The PostGIS adapter (`activerecord-postgis-adapter`) reports `"PostGIS"` and gets everything PostgreSQL gets: advisory locks, the isolation check, JSONB and native UUIDs. An adapter that is no such subclass is known by its name: one that contains `postg`, `mysql`, `trilogy` or `sqlite`. Any other adapter raises `Bouncy::ConfigurationError` at its first lock. The suite runs MySQL through `mysql2` only; the Trilogy adapter shares `AbstractMysqlAdapter` with it and is recognised by its class and its name, but no suite run uses it.
|
|
14
|
+
|
|
9
15
|
The runtime JSON gem is constrained below version 3 because the supported Rails releases still pass a positional options hash to `JSON.parse`. Optional AWS SDKs load lazily and are host dependencies. There is no database or AWS lookup during boot.
|
|
10
16
|
|
|
11
17
|
## Migrations
|
|
@@ -25,11 +31,11 @@ Without a setting, Rails chooses its standard primary key. Explicit bigint/integ
|
|
|
25
31
|
|
|
26
32
|
| Database | JSON storage | JSON defaults | UUID storage |
|
|
27
33
|
|---|---|---|---|
|
|
28
|
-
| PostgreSQL | JSONB | Database and model defaults | Native UUID |
|
|
34
|
+
| PostgreSQL, PostGIS included | JSONB | Database and model defaults | Native UUID |
|
|
29
35
|
| MySQL 8.4 | JSON | Model defaults; no literal SQL JSON default | 36-character string |
|
|
30
36
|
| SQLite | JSON | Database and model defaults | 36-character string |
|
|
31
37
|
|
|
32
|
-
The gem's JSON attributes inherit the database's native type. Every new record gets an independent hash/array default. No adapter probe or attribute-schema query is performed at boot. Unsupported database adapters raise before reconciliation.
|
|
38
|
+
The gem's JSON attributes inherit the database's native type. Every new record gets an independent hash/array default. No adapter probe or attribute-schema query is performed at boot. Unsupported database adapters raise before reconciliation. Before 0.2.2 the migration made JSON columns and 36-character string keys on PostGIS; those tables keep working.
|
|
33
39
|
|
|
34
40
|
MySQL identity columns explicitly use `utf8mb4_bin`. Case/accent-insensitive equality must not collapse addresses beyond Bouncy's trimmed lowercase policy. Scope and address limits keep compound indexes within supported database key limits. Use an application-normalized host column for model scopes.
|
|
35
41
|
|
data/lib/bouncy/address_lock.rb
CHANGED
|
@@ -170,7 +170,7 @@ module Bouncy
|
|
|
170
170
|
# stays invisible. Hold the address outside the transaction instead; the lock then lasts for
|
|
171
171
|
# the whole block, and the transaction's snapshot starts after it.
|
|
172
172
|
def require_read_committed(connection)
|
|
173
|
-
return unless connection
|
|
173
|
+
return unless Database.family(connection) == :postgresql && connection.transaction_open?
|
|
174
174
|
|
|
175
175
|
isolation = connection.uncached { connection.select_value("SHOW transaction_isolation") }
|
|
176
176
|
return if ["read committed", "read uncommitted"].include?(isolation)
|
|
@@ -181,11 +181,10 @@ module Bouncy
|
|
|
181
181
|
|
|
182
182
|
# Takes the lock now and returns what releases it, or nil when the database releases it.
|
|
183
183
|
def take(connection, key, exclusive:)
|
|
184
|
-
case connection
|
|
185
|
-
when
|
|
186
|
-
when
|
|
187
|
-
when
|
|
188
|
-
else raise ConfigurationError, "Bouncy supports PostgreSQL, MySQL and SQLite"
|
|
184
|
+
case Database.family!(connection)
|
|
185
|
+
when :postgresql then take_advisory(connection, key, exclusive)
|
|
186
|
+
when :mysql then take_named(connection, key)
|
|
187
|
+
when :sqlite then take_file(connection, exclusive ? File::LOCK_EX : File::LOCK_SH)
|
|
189
188
|
end
|
|
190
189
|
end
|
|
191
190
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bouncy
|
|
4
|
+
# Names the database family of a connection, which decides the lock Bouncy takes. A subclass
|
|
5
|
+
# of a Rails adapter belongs to the family of its parent class: the PostGIS adapter is a
|
|
6
|
+
# PostgreSQLAdapter whose adapter_name is "PostGIS", and the Trilogy and Mysql2 adapters share
|
|
7
|
+
# AbstractMysqlAdapter. The adapter_name is the fallback for an adapter that only renames
|
|
8
|
+
# itself. Each class is looked up only once its adapter is loaded, so no driver gem loads.
|
|
9
|
+
module Database
|
|
10
|
+
FAMILIES = {
|
|
11
|
+
postgresql: ["PostgreSQLAdapter", /postg/i],
|
|
12
|
+
mysql: ["AbstractMysqlAdapter", /mysql|trilogy/i],
|
|
13
|
+
sqlite: ["SQLite3Adapter", /sqlite/i]
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# :postgresql, :mysql, :sqlite, or nil for any other database.
|
|
19
|
+
def family(connection)
|
|
20
|
+
FAMILIES.find { |_, (adapter, _)| subclass?(connection, adapter) }&.first ||
|
|
21
|
+
FAMILIES.find { |_, (_, name)| connection.adapter_name.match?(name) }&.first
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def family!(connection)
|
|
25
|
+
family(connection) || raise(ConfigurationError, "Bouncy supports PostgreSQL, MySQL and SQLite")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def subclass?(connection, adapter)
|
|
29
|
+
ActiveRecord::ConnectionAdapters.const_defined?(adapter, false) &&
|
|
30
|
+
connection.is_a?(ActiveRecord::ConnectionAdapters.const_get(adapter, false))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/bouncy/scope_lock.rb
CHANGED
|
@@ -11,8 +11,8 @@ module Bouncy
|
|
|
11
11
|
def synchronize
|
|
12
12
|
Record.connection_pool.with_connection do |connection|
|
|
13
13
|
key = Digest::SHA256.hexdigest(Bouncy.scope)[0, 15].to_i(16)
|
|
14
|
-
case connection
|
|
15
|
-
when
|
|
14
|
+
case Database.family!(connection)
|
|
15
|
+
when :postgresql
|
|
16
16
|
# Uncached: a cached answer would report the lock without taking it.
|
|
17
17
|
acquired = connection.uncached { connection.select_value("SELECT pg_try_advisory_lock(#{key})") }
|
|
18
18
|
raise ProviderError, "Another sync holds this scope lock" unless [true, "t"].include?(acquired)
|
|
@@ -22,7 +22,7 @@ module Bouncy
|
|
|
22
22
|
ensure
|
|
23
23
|
connection.execute("SELECT pg_advisory_unlock(#{key})")
|
|
24
24
|
end
|
|
25
|
-
when
|
|
25
|
+
when :mysql
|
|
26
26
|
name = Digest::SHA256.hexdigest("bouncy:#{connection.pool.db_config.database}:#{Bouncy.scope}")
|
|
27
27
|
acquired = connection.uncached { connection.select_value("SELECT GET_LOCK('#{name}', 0)") }
|
|
28
28
|
raise ProviderError, "Another sync holds this scope lock" unless acquired == 1
|
|
@@ -32,7 +32,7 @@ module Bouncy
|
|
|
32
32
|
ensure
|
|
33
33
|
connection.execute("SELECT RELEASE_LOCK('#{name}')")
|
|
34
34
|
end
|
|
35
|
-
when
|
|
35
|
+
when :sqlite
|
|
36
36
|
database = connection.pool.db_config.database
|
|
37
37
|
path = database == ":memory:" ? File.join(Dir.tmpdir, "bouncy-#{Process.pid}-#{key}.lock") : "#{File.expand_path(database)}.bouncy-#{key}.lock"
|
|
38
38
|
File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
|
|
@@ -44,8 +44,6 @@ module Bouncy
|
|
|
44
44
|
file.flock(File::LOCK_UN)
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
|
-
else
|
|
48
|
-
raise ConfigurationError, "Bouncy supports PostgreSQL, MySQL and SQLite"
|
|
49
47
|
end
|
|
50
48
|
end
|
|
51
49
|
end
|
data/lib/bouncy/version.rb
CHANGED
data/lib/bouncy.rb
CHANGED
|
@@ -173,7 +173,7 @@ module Bouncy
|
|
|
173
173
|
# committed state: a REPEATABLE READ snapshot from before the hold cannot hide a newer block
|
|
174
174
|
# or release. PostgreSQL at READ COMMITTED reads the latest state with every statement.
|
|
175
175
|
def current_state(relation)
|
|
176
|
-
return relation unless AddressLock.holding? && Record.connection_pool.with_connection
|
|
176
|
+
return relation unless AddressLock.holding? && Record.connection_pool.with_connection { |connection| Database.family(connection) } == :mysql
|
|
177
177
|
|
|
178
178
|
relation.lock("LOCK IN SHARE MODE")
|
|
179
179
|
end
|
|
@@ -201,6 +201,7 @@ end
|
|
|
201
201
|
|
|
202
202
|
require_relative "bouncy/configuration"
|
|
203
203
|
require_relative "bouncy/identity"
|
|
204
|
+
require_relative "bouncy/database"
|
|
204
205
|
require_relative "bouncy/record"
|
|
205
206
|
require_relative "bouncy/suppression"
|
|
206
207
|
require_relative "bouncy/event"
|
|
@@ -87,11 +87,25 @@ class CreateBouncyTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
87
87
|
mysql? ? { collation: "utf8mb4_bin" } : {}
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
def postgresql?
|
|
91
|
-
|
|
90
|
+
def postgresql? = adapter_family == :postgresql
|
|
91
|
+
def mysql? = adapter_family == :mysql
|
|
92
|
+
|
|
93
|
+
# Mirrors Bouncy::Database.family. It is inlined because the migration must not depend on
|
|
94
|
+
# Bouncy's code when it runs. Every adapter class comes before any name: a subclass of a Rails
|
|
95
|
+
# adapter keeps the family of its parent class whatever it reports, as the PostGIS adapter
|
|
96
|
+
# reports "PostGIS". The name is the fallback for an adapter that only renames itself.
|
|
97
|
+
def adapter_family
|
|
98
|
+
families = {
|
|
99
|
+
postgresql: ["PostgreSQLAdapter", /postg/i],
|
|
100
|
+
mysql: ["AbstractMysqlAdapter", /mysql|trilogy/i],
|
|
101
|
+
sqlite: ["SQLite3Adapter", /sqlite/i]
|
|
102
|
+
}
|
|
103
|
+
families.find { |_, (adapter, _)| adapter_subclass?(adapter) }&.first ||
|
|
104
|
+
families.find { |_, (_, name)| connection.adapter_name.match?(name) }&.first
|
|
92
105
|
end
|
|
93
106
|
|
|
94
|
-
def
|
|
95
|
-
|
|
107
|
+
def adapter_subclass?(adapter)
|
|
108
|
+
ActiveRecord::ConnectionAdapters.const_defined?(adapter, false) &&
|
|
109
|
+
connection.is_a?(ActiveRecord::ConnectionAdapters.const_get(adapter, false))
|
|
96
110
|
end
|
|
97
111
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bouncy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -79,6 +79,7 @@ files:
|
|
|
79
79
|
- lib/bouncy/address_lock.rb
|
|
80
80
|
- lib/bouncy/body_limit.rb
|
|
81
81
|
- lib/bouncy/configuration.rb
|
|
82
|
+
- lib/bouncy/database.rb
|
|
82
83
|
- lib/bouncy/engine.rb
|
|
83
84
|
- lib/bouncy/event.rb
|
|
84
85
|
- lib/bouncy/identity.rb
|