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 +4 -4
- data/CHANGELOG.md +6 -1
- data/README.md +2 -39
- data/lib/positioning/healer.rb +23 -6
- data/lib/positioning/mechanisms.rb +44 -10
- data/lib/positioning/version.rb +1 -1
- data/lib/positioning.rb +11 -16
- metadata +2 -3
- data/lib/positioning/advisory_lock.rb +0 -82
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 98446468efcfe543a72a5bf354b5e8c8408951dfc15072968df64500dc9e0275
|
4
|
+
data.tar.gz: a814a905f38b39064d8871a883b3bb4d68b2c446f0cec33d93f4c37671fcc33c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.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
|
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
|
-
|
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.
|
data/lib/positioning/healer.rb
CHANGED
@@ -7,19 +7,36 @@ module Positioning
|
|
7
7
|
end
|
8
8
|
|
9
9
|
def heal
|
10
|
-
if
|
11
|
-
@model.select(*
|
12
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
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
|
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(*
|
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(*
|
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
|
-
|
179
|
-
@positioned.attribute_changed?(
|
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 &&
|
185
|
-
@positioned.destroyed_by_association.foreign_key ==
|
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
|
data/lib/positioning/version.rb
CHANGED
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
|
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] =
|
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
|
-
|
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
|
-
|
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.
|
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-
|
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
|