positioning 0.3.0 → 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: 2b3dd60b08d2b1488b5fd6c88681f83cd37b46dd2eb0f27557ca4fb131928c9b
4
- data.tar.gz: 442d3030455cadbcaa2e4fe0f97bc4631d9081194aff31c67242ec5675bc5f8e
3
+ metadata.gz: 98446468efcfe543a72a5bf354b5e8c8408951dfc15072968df64500dc9e0275
4
+ data.tar.gz: a814a905f38b39064d8871a883b3bb4d68b2c446f0cec33d93f4c37671fcc33c
5
5
  SHA512:
6
- metadata.gz: 2c199c7f7d79ca57833593e3bfbe2e47161875943aabef8247c9cabb7b35e5c22297b6b69f298fc2c3df8ee0e9c5f1d99dc6c41e8e90b979cddd205c2d4b91b6
7
- data.tar.gz: f6e070723c2178c5b7e4907e43ae8fcf2a9f50611b37179fc618ffa220205382f01f8aaa907a0a0cea1bd2aa0e14baf623401678bebc5e7b5ccbc80f2f3f31eb
6
+ metadata.gz: 339a65265898c0b0e7b0e1758e708f57b4ee49d3b8fad1e93489058e6cbc047022e8d0cd42fcbe7793dad1a52dc04784e4122ce9e53d4360988f77f296f45c9f
7
+ data.tar.gz: 5443a095efe22bb69a079d51ae3bea37b78bd7035a3f0e07580c45006bbff98d44af7aeb17771622a4d33e98f8d0b3b957929a0b6a84ff54d3696684c3534ff2
data/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.3.0] - 2024-08-21
3
+ ## [0.4.0] - 2024-10-12
4
+
5
+ - BREAKING CHANGE: Advisory Lock has been removed. If you explicitly define `advisory_lock: false` in your `positioned` call, you'll need to remove this.
6
+ - CAUTION: The Advisory Lock replacement is row locking. Where `belongs_to` associations exist, we lock the associated record(s), and that limits the locking scope down to the record's current scope, and potentially the scope it belonged to before a change in scope. If there are no `belongs_to` associations then the records that belong to the current (and potentially new) scope are locked, or all the records in the table are locked if there is no scope. Please report any deadlock issues.
7
+
8
+ ## [0.3.0] - 2024-10-12
4
9
 
5
10
  - POSSIBLY BREAKING: Clear all position columns on a duplicate created with `dup`.
6
11
 
data/README.md CHANGED
@@ -265,49 +265,12 @@ It's important to note that in the examples above, `other_item` must already bel
265
265
 
266
266
  ## Concurrency
267
267
 
268
- The queries that this gem runs, especially those that seek the next position integer available are vulnerable to race conditions. To this end, we've introduced an Advisory Lock to ensure that our model callbacks that determine and assign positions run sequentially. In short, an advisory lock prevents more than one process from running a particular block of code at the same time. The lock occurs in the database (or in the case of SQLite, on the filesystem), so as long as all of your processes are using the same database, the lock will prevent multiple positioning callbacks from executing on the same table and positioning column combination at the same time.
268
+ The queries that this gem runs (especially those that seek the next position integer available) are vulnerable to race conditions. To this end, we lock the scope records to ensure that our model callbacks that determine and assign positions run sequentially. Previously we used an Advisory Lock for this purpose but this was difficult to test and a bit overkill in most situations. Where a scope doesn't exist, we lock all the records in the table.
269
269
 
270
- If you are using SQLite, you'll want to add the following line to your database.yml file in order to increase the exclusivity of Active Record's default write transactions:
271
-
272
- ```yaml
273
- default_transaction_mode: EXCLUSIVE
274
- ```
275
-
276
- You may also want to try `IMMEDIATE` as a less aggressive alternative.
277
-
278
- You're encouraged to review the Advisory Lock code to ensure it fits with your environment:
279
-
280
- https://github.com/brendon/positioning/blob/main/lib/positioning/advisory_lock.rb
270
+ **Please Note SQLite Users:** Row locking isn't supported by SQLite. Since writes are non-concurrent by default, the worst you'll probably see are errors about the database being locked under high load.
281
271
 
282
272
  If you have any concerns or improvements please file a GitHub issue.
283
273
 
284
- ### Opting out of Advisory Lock
285
-
286
- There are cases where Advisory Lock may be unwanted or unnecessary, for instance, if you already lock the parent record in **every** operation that will touch the database on the positioned item, **everywhere** in your application.
287
-
288
- Example of such scenario in your application:
289
-
290
- ```ruby
291
- list = List.create(name: "List")
292
-
293
- list.with_lock do
294
- item_a = list.items.create(name: "Item A")
295
- item_b = list.items.create(name: "Item B")
296
- item_c = list.items.create(name: "Item C")
297
-
298
- item_c.update(position: {before: item_a})
299
-
300
- item_a.destroy
301
- end
302
- ```
303
-
304
- Therefore, making sure you already have another mechanism to avoid race conditions, you can opt out of Advisory Lock by setting `advisory_lock` to `false` when declaring positioning:
305
-
306
- ```ruby
307
- belongs_to :list
308
- positioned on: :list, advisory_lock: false
309
- ```
310
-
311
274
  ## Development
312
275
 
313
276
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -7,19 +7,36 @@ module Positioning
7
7
  end
8
8
 
9
9
  def heal
10
- if positioning_columns.present?
11
- @model.select(*positioning_columns).distinct.each do |scope_record|
12
- sequence @model.where(scope_record.slice(*positioning_columns))
10
+ if scope_columns.present?
11
+ @model.select(*scope_columns).distinct.each do |scope_record|
12
+ @model.transaction do
13
+ if scope_associations.present?
14
+ scope_associations.each do |scope_association|
15
+ scope_record.send(scope_association).lock!
16
+ end
17
+ else
18
+ @model.where(scope_record.slice(*scope_columns)).lock!
19
+ end
20
+
21
+ sequence @model.where(scope_record.slice(*scope_columns))
22
+ end
13
23
  end
14
24
  else
15
- sequence @model
25
+ @model.transaction do
26
+ @model.all.lock!
27
+ sequence @model
28
+ end
16
29
  end
17
30
  end
18
31
 
19
32
  private
20
33
 
21
- def positioning_columns
22
- @model.positioning_columns[@column]
34
+ def scope_columns
35
+ @model.positioning_columns[@column][:scope_columns]
36
+ end
37
+
38
+ def scope_associations
39
+ @model.positioning_columns[@column][:scope_associations]
23
40
  end
24
41
 
25
42
  def sequence(scope)
@@ -14,6 +14,8 @@ module Positioning
14
14
  end
15
15
 
16
16
  def create_position
17
+ lock_positioning_scope!
18
+
17
19
  solidify_position
18
20
 
19
21
  expand(positioning_scope, position..)
@@ -22,6 +24,8 @@ module Positioning
22
24
  def update_position
23
25
  return unless positioning_scope_changed? || position_changed?
24
26
 
27
+ lock_positioning_scope!
28
+
25
29
  clear_position if positioning_scope_changed? && !position_changed?
26
30
 
27
31
  solidify_position
@@ -39,6 +43,8 @@ module Positioning
39
43
 
40
44
  def destroy_position
41
45
  unless destroyed_via_positioning_scope?
46
+ lock_positioning_scope!
47
+
42
48
  move_out_of_the_way
43
49
  contract(positioning_scope, (position_was + 1)..)
44
50
  end
@@ -50,16 +56,28 @@ module Positioning
50
56
  @positioned.class.base_class
51
57
  end
52
58
 
59
+ def with_connection
60
+ if base_class.respond_to? :with_connection
61
+ base_class.with_connection do |connection|
62
+ yield connection
63
+ end
64
+ else
65
+ yield base_class.connection
66
+ end
67
+ end
68
+
53
69
  def primary_key
54
70
  base_class.primary_key
55
71
  end
56
72
 
57
73
  def quoted_column
58
- base_class.connection.quote_table_name_for_assignment base_class.table_name, @column
74
+ with_connection do |connection|
75
+ connection.quote_table_name_for_assignment base_class.table_name, @column
76
+ end
59
77
  end
60
78
 
61
79
  def record_scope
62
- base_class.where(primary_key => [@positioned.id])
80
+ base_class.where primary_key => [@positioned.id]
63
81
  end
64
82
 
65
83
  def position
@@ -158,16 +176,32 @@ module Positioning
158
176
  (positioning_scope.maximum(@column) || 0) + (in_positioning_scope? ? 0 : 1)
159
177
  end
160
178
 
161
- def positioning_columns
162
- base_class.positioning_columns[@column]
179
+ def scope_columns
180
+ base_class.positioning_columns[@column][:scope_columns]
181
+ end
182
+
183
+ def scope_associations
184
+ base_class.positioning_columns[@column][:scope_associations]
163
185
  end
164
186
 
165
187
  def positioning_scope
166
- base_class.where @positioned.slice(*positioning_columns)
188
+ base_class.where @positioned.slice(*scope_columns)
189
+ end
190
+
191
+ def lock_positioning_scope!
192
+ if scope_associations.present?
193
+ scope_associations.each do |scope_association|
194
+ record_scope.first.send(scope_association).lock! if @positioned.persisted? && positioning_scope_changed?
195
+ @positioned.send(scope_association).lock!
196
+ end
197
+ else
198
+ positioning_scope_was.lock! if @positioned.persisted? && positioning_scope_changed?
199
+ positioning_scope.lock!
200
+ end
167
201
  end
168
202
 
169
203
  def positioning_scope_was
170
- base_class.where record_scope.first.slice(*positioning_columns)
204
+ base_class.where record_scope.first.slice(*scope_columns)
171
205
  end
172
206
 
173
207
  def in_positioning_scope?
@@ -175,14 +209,14 @@ module Positioning
175
209
  end
176
210
 
177
211
  def positioning_scope_changed?
178
- positioning_columns.any? do |scope_component|
179
- @positioned.attribute_changed?(scope_component)
212
+ scope_columns.any? do |scope_column|
213
+ @positioned.attribute_changed?(scope_column)
180
214
  end
181
215
  end
182
216
 
183
217
  def destroyed_via_positioning_scope?
184
- @positioned.destroyed_by_association && positioning_columns.any? do |scope_component|
185
- @positioned.destroyed_by_association.foreign_key == scope_component
218
+ @positioned.destroyed_by_association && scope_columns.any? do |scope_column|
219
+ @positioned.destroyed_by_association.foreign_key == scope_column
186
220
  end
187
221
  end
188
222
  end
@@ -1,3 +1,3 @@
1
1
  module Positioning
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/positioning.rb CHANGED
@@ -1,5 +1,4 @@
1
1
  require_relative "positioning/version"
2
- require_relative "positioning/advisory_lock"
3
2
  require_relative "positioning/mechanisms"
4
3
  require_relative "positioning/healer"
5
4
 
@@ -19,7 +18,7 @@ module Positioning
19
18
  @positioning_columns ||= {}
20
19
  end
21
20
 
22
- def positioned(on: [], column: :position, advisory_lock: true)
21
+ def positioned(on: [], column: :position)
23
22
  unless base_class?
24
23
  raise Error.new "can't be called on an abstract class or STI subclass."
25
24
  end
@@ -29,11 +28,18 @@ module Positioning
29
28
  if positioning_columns.key? column
30
29
  raise Error.new "The column `#{column}` has already been used by the scope `#{positioning_columns[column]}`."
31
30
  else
32
- positioning_columns[column] = Array.wrap(on).map do |scope_component|
31
+ positioning_columns[column] = {scope_columns: [], scope_associations: []}
32
+
33
+ Array.wrap(on).each do |scope_component|
33
34
  scope_component = scope_component.to_s
34
35
  reflection = reflections[scope_component]
35
36
 
36
- (reflection && reflection.belongs_to?) ? reflection.foreign_key : scope_component
37
+ if reflection&.belongs_to?
38
+ positioning_columns[column][:scope_columns] << reflection.foreign_key
39
+ positioning_columns[column][:scope_associations] << reflection.name
40
+ else
41
+ positioning_columns[column][:scope_columns] << scope_component
42
+ end
37
43
  end
38
44
 
39
45
  define_method(:"prior_#{column}") { Mechanisms.new(self, column).prior }
@@ -44,23 +50,12 @@ module Positioning
44
50
  super(position)
45
51
  end
46
52
 
47
- advisory_locker = AdvisoryLock.new(base_class, column, advisory_lock)
48
-
49
- before_create { advisory_locker.acquire }
50
- before_update { advisory_locker.acquire }
51
- before_destroy { advisory_locker.acquire }
52
-
53
53
  before_create { Mechanisms.new(self, column).create_position }
54
54
  before_update { Mechanisms.new(self, column).update_position }
55
55
  before_destroy { Mechanisms.new(self, column).destroy_position }
56
56
 
57
- after_commit { advisory_locker.release }
58
- after_rollback { advisory_locker.release }
59
-
60
57
  define_singleton_method(:"heal_#{column}_column!") do |order = column|
61
- advisory_locker.acquire do
62
- Healer.new(self, column, order).heal
63
- end
58
+ Healer.new(self, column, order).heal
64
59
  end
65
60
  end
66
61
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: positioning
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brendon Muir
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-10-11 00:00:00.000000000 Z
11
+ date: 2024-11-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -122,7 +122,6 @@ files:
122
122
  - README.md
123
123
  - Rakefile
124
124
  - lib/positioning.rb
125
- - lib/positioning/advisory_lock.rb
126
125
  - lib/positioning/healer.rb
127
126
  - lib/positioning/mechanisms.rb
128
127
  - lib/positioning/version.rb
@@ -1,82 +0,0 @@
1
- require "fileutils"
2
- require "openssl"
3
-
4
- module Positioning
5
- class AdvisoryLock
6
- Adapter = Struct.new(:initialise, :acquire, :release, keyword_init: true)
7
-
8
- attr_reader :base_class
9
-
10
- def initialize(base_class, column, enabled)
11
- @base_class = base_class
12
- @column = column.to_s
13
- @enabled = enabled
14
-
15
- @adapters = {
16
- "mysql2" => Adapter.new(
17
- initialise: -> {},
18
- acquire: -> { connection.execute "SELECT GET_LOCK(#{connection.quote(lock_name)}, -1)" },
19
- release: -> { connection.execute "SELECT RELEASE_LOCK(#{connection.quote(lock_name)})" }
20
- ),
21
- "postgresql" => Adapter.new(
22
- initialise: -> {},
23
- acquire: -> { connection.execute "SELECT pg_advisory_lock(#{lock_name.hex & 0x7FFFFFFFFFFFFFFF})" },
24
- release: -> { connection.execute "SELECT pg_advisory_unlock(#{lock_name.hex & 0x7FFFFFFFFFFFFFFF})" }
25
- ),
26
- "sqlite3" => Adapter.new(
27
- initialise: -> {
28
- FileUtils.mkdir_p "#{Dir.pwd}/tmp"
29
- filename = "#{Dir.pwd}/tmp/#{lock_name}.lock"
30
- @file ||= File.open filename, File::RDWR | File::CREAT, 0o644
31
- },
32
- acquire: -> {
33
- @file.flock File::LOCK_EX
34
- },
35
- release: -> {
36
- @file.flock File::LOCK_UN
37
- }
38
- )
39
- }
40
-
41
- @adapters.default = Adapter.new(initialise: -> {}, acquire: -> {}, release: -> {})
42
-
43
- adapter.initialise.call if @enabled
44
- end
45
-
46
- def acquire
47
- adapter.acquire.call if @enabled
48
-
49
- if block_given?
50
- yield
51
- adapter.release.call if @enabled
52
- end
53
- end
54
-
55
- def release
56
- adapter.release.call if @enabled
57
- end
58
-
59
- private
60
-
61
- def connection
62
- base_class.connection
63
- end
64
-
65
- def adapter_name
66
- base_class.connection_db_config.adapter
67
- end
68
-
69
- def adapter
70
- @adapters[adapter_name]
71
- end
72
-
73
- def lock_name
74
- lock_name = ["positioning"]
75
- lock_name << connection.current_database if connection.respond_to?(:current_database)
76
- lock_name << base_class.table_name
77
- lock_name << @column
78
-
79
- OpenSSL::Digest::MD5.hexdigest(lock_name.join("."))[0...32]
80
- end
81
- end
82
- end