tarantool-model 0.2.0 → 0.3.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/CHANGELOG.md +8 -0
- data/README.md +43 -13
- data/lib/tarantool/model/errors.rb +11 -0
- data/lib/tarantool/model/locking.rb +90 -0
- data/lib/tarantool/model/persistence.rb +10 -2
- data/lib/tarantool/model/settings.rb +10 -1
- data/lib/tarantool/model/storage/box.rb +2 -0
- data/lib/tarantool/model/storage/crud.rb +2 -0
- data/lib/tarantool/model/testing/memory_storage/box_mode.rb +2 -0
- data/lib/tarantool/model/testing/memory_storage/crud_mode.rb +2 -0
- data/lib/tarantool/model/testing/memory_storage.rb +2 -0
- data/lib/tarantool/model/version.rb +1 -1
- data/lib/tarantool/model.rb +18 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dfebd43ac5f258976a8257dae17d23a26a607534c97c83303dc020f8fc7d3e82
|
|
4
|
+
data.tar.gz: 8aee6cf2bfc9c2e16dee2440ebfb4292f6458eeacb355ebe4a40bebdb5d8a43b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8a4161e4bbf0f5593ef3d0a0b2cc8b2a2b49c5456466e4e1252e8ffccb8ec497a10c455fb2389da59f5dd0b30e4c0a5baaec3bd400a1d91174f7158c2520e9d3
|
|
7
|
+
data.tar.gz: 953f19659c46174dd935642bb8f99a4d846cc7dc892f9c7bbc992ee5e1272851a7d1526edfbec71ce1ec6581778a273ec86f5cd94cbee0b73086e6f253c0ef40
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
- `Tarantool::Model.default_storage`: a storage, or a Proc called once on first use, for models without one of
|
|
6
|
+
their own.
|
|
7
|
+
- Optimistic locking on the `locking_column` attribute (`lock_version` by default): update and destroy compare
|
|
8
|
+
the stored version with the record's and bump it, in a transaction on `Storage::Box`; `StaleObjectError`.
|
|
9
|
+
- Storages answer `supports_transactions?`.
|
|
10
|
+
|
|
3
11
|
## 0.2.0 (2026-10-17)
|
|
4
12
|
|
|
5
13
|
- `Storage::Box` converts rows with tarantool-client (`as: :row`), by the format of the schema each request ran against, and no longer looks up `space_info` before every operation.
|
data/README.md
CHANGED
|
@@ -22,19 +22,14 @@ require 'tarantool/model'
|
|
|
22
22
|
```ruby
|
|
23
23
|
pool = Tarantool::Client::Pool.new(['tcp://app:secret@db1:3301', 'tcp://app:secret@db2:3301'])
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
read: { mode: :prefer_ro },
|
|
31
|
-
write: { mode: :rw }
|
|
32
|
-
)
|
|
33
|
-
end
|
|
34
|
-
end
|
|
25
|
+
Tarantool::Model.default_storage = Tarantool::Model::Storage::Box.new(
|
|
26
|
+
pool,
|
|
27
|
+
read: { mode: :prefer_ro },
|
|
28
|
+
write: { mode: :rw }
|
|
29
|
+
)
|
|
35
30
|
|
|
36
31
|
class User
|
|
37
|
-
include
|
|
32
|
+
include Tarantool::Model
|
|
38
33
|
|
|
39
34
|
attribute :id, :integer
|
|
40
35
|
attribute :email, :string
|
|
@@ -44,6 +39,10 @@ class User
|
|
|
44
39
|
end
|
|
45
40
|
```
|
|
46
41
|
|
|
42
|
+
`Tarantool::Model.default_storage` serves every model that sets no `storage` of its own; `User.storage = ...`
|
|
43
|
+
overrides it per model. A Proc assigned to it (`Tarantool::Model.default_storage = -> { build_storage }`) is
|
|
44
|
+
called once, on first use, so the connection is opened only when a model needs it.
|
|
45
|
+
|
|
47
46
|
`Storage::Box` passes its `read:`/`write:` options verbatim to the client's read and write requests. The `Pool`
|
|
48
47
|
routing `mode:` (symbols like `:prefer_ro`, `:rw`; requires a `Pool` as transport) is one example; `timeout:` works
|
|
49
48
|
too. `Storage::Crud` takes no `read:`/`write:`: crud defaults are set on `Tarantool::Crud::Client.new(..., read:,
|
|
@@ -119,6 +118,33 @@ user.decrement!(:age)
|
|
|
119
118
|
Counters skip validations and callbacks. Only the counter attribute and the raw tuple are refreshed from the
|
|
120
119
|
response; other unsaved changes on the record survive. Raises `RecordNotFound` if the row is gone.
|
|
121
120
|
|
|
121
|
+
### Optimistic locking
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
class Account
|
|
125
|
+
include Tarantool::Model
|
|
126
|
+
|
|
127
|
+
attribute :id, :integer
|
|
128
|
+
attribute :balance, :decimal
|
|
129
|
+
attribute :lock_version, :integer, default: 0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
Account.locking_column # => :lock_version
|
|
133
|
+
Account.locking_column = :version # another attribute, or nil to disable
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Locking is on when `locking_column` names a declared attribute; the space needs that field. A nil version is
|
|
137
|
+
stored as 0 on create. On update, the row is read again and its version compared with the record's (a value
|
|
138
|
+
assigned by hand, for example from a form field, counts); a different version raises `StaleObjectError`, and so
|
|
139
|
+
does a row changed underneath on destroy. The update sends the changed attributes plus the version incremented
|
|
140
|
+
by one, and the record picks the new version up. Only the version changed sends nothing.
|
|
141
|
+
|
|
142
|
+
On `Storage::Box` the check and the update run in one transaction (MVCC must be on, as for `transaction`), so a
|
|
143
|
+
concurrent commit on the same row between them surfaces as `StaleObjectError` too. Inside `Model.transaction` no
|
|
144
|
+
second transaction is opened; the check joins the open one. `Storage::Crud` has no transactions: the check runs
|
|
145
|
+
unguarded, and a write between the read and the update goes unnoticed. Counters (`increment!`, `decrement!`)
|
|
146
|
+
bypass locking and leave the version alone.
|
|
147
|
+
|
|
122
148
|
## Querying
|
|
123
149
|
|
|
124
150
|
```ruby
|
|
@@ -259,6 +285,7 @@ A tarantool-schema definition can be passed as is: `MemoryStorage.from_definitio
|
|
|
259
285
|
| unknown space | `Tarantool::SchemaError` | `Tarantool::Crud::<Operation>Error` |
|
|
260
286
|
| `count` | `NotSupportedError` | counts |
|
|
261
287
|
| `transaction` | rolls back when the block raises | `NotSupportedError` |
|
|
288
|
+
| `supports_transactions?` | `true` | `false` |
|
|
262
289
|
| single-row results | with a tuple | `tuple` is nil |
|
|
263
290
|
|
|
264
291
|
`verify` (the schema check `Storage::Box` runs on first use) does nothing in `:crud` mode. A nil primary key gets
|
|
@@ -288,12 +315,14 @@ Not modelled:
|
|
|
288
315
|
`to_param` joins a composite key with `-` (e.g. `"7-1"`) for route helpers. `as_json` works through
|
|
289
316
|
`ActiveModel::Serializers::JSON`. `form_with` works with any persisted model.
|
|
290
317
|
|
|
291
|
-
|
|
318
|
+
`tarantool-rails` configures the storage from `config/tarantool.yml`. Without it, set
|
|
319
|
+
`Tarantool::Model.default_storage` in an initializer:
|
|
292
320
|
|
|
293
321
|
```ruby
|
|
294
322
|
# config/initializers/tarantool.rb
|
|
295
323
|
pool = Tarantool::Client::Pool.new(Rails.application.config.tarantool_uris)
|
|
296
|
-
|
|
324
|
+
Tarantool::Model.default_storage =
|
|
325
|
+
Tarantool::Model::Storage::Box.new(pool, read: { mode: :prefer_ro }, write: { mode: :rw })
|
|
297
326
|
```
|
|
298
327
|
|
|
299
328
|
## Errors
|
|
@@ -307,6 +336,7 @@ Tarantool::Model::Error
|
|
|
307
336
|
├── RecordInvalid # #record
|
|
308
337
|
├── RecordNotSaved # #record; a before callback aborted
|
|
309
338
|
├── RecordNotUnique # duplicate primary or unique secondary key
|
|
339
|
+
├── StaleObjectError # #record, #attempted_action; the row's version changed since the record was loaded
|
|
310
340
|
├── SchemaMismatchError # attribute or primary key does not match the space format
|
|
311
341
|
├── NotSupportedError # operation not available for this storage (e.g. count on Storage::Box)
|
|
312
342
|
└── TransactionMismatchError # write through a storage that has no open transaction in the current fiber
|
|
@@ -35,6 +35,17 @@ module Tarantool
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# The row's locking column changed since the record was loaded.
|
|
39
|
+
class StaleObjectError < Error
|
|
40
|
+
attr_reader :record, :attempted_action
|
|
41
|
+
|
|
42
|
+
def initialize(record, attempted_action)
|
|
43
|
+
@record = record
|
|
44
|
+
@attempted_action = attempted_action
|
|
45
|
+
super("Attempted to #{attempted_action} a stale #{record.class.name} with key #{record.to_key.inspect}")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
38
49
|
class RecordNotUnique < Error; end
|
|
39
50
|
class SchemaMismatchError < Error; end
|
|
40
51
|
class TransactionMismatchError < Error; end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# Optimistic locking on the attribute named by locking_column, when the model declares it. An update compares
|
|
6
|
+
# the stored version with the record's and bumps it, inside a transaction on storages that have them, so a
|
|
7
|
+
# concurrent commit on the same row surfaces as StaleObjectError instead of being overwritten.
|
|
8
|
+
module Locking
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
ER_TRANSACTION_CONFLICT = 97
|
|
12
|
+
private_constant :ER_TRANSACTION_CONFLICT
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
class_attribute :locking_column, instance_accessor: false, default: :lock_version
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class_methods do
|
|
19
|
+
def locking_enabled?
|
|
20
|
+
!locking_column.nil? && attribute_names.include?(locking_column.to_s)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def create_row
|
|
27
|
+
public_send("#{locking_column}=", 0) if locking_enabled? && public_send(locking_column).nil?
|
|
28
|
+
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def update_pending?
|
|
33
|
+
return super unless locking_enabled?
|
|
34
|
+
|
|
35
|
+
changes.except(locking_column.to_s).any?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def updated_row
|
|
39
|
+
return super unless locking_enabled?
|
|
40
|
+
|
|
41
|
+
atomically(:update) do
|
|
42
|
+
locked_row(:update) || raise(RecordNotFound.new(self.class, key_in_database))
|
|
43
|
+
|
|
44
|
+
write_row(locked_operations)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def delete_row
|
|
49
|
+
return super unless locking_enabled? && persisted?
|
|
50
|
+
|
|
51
|
+
atomically(:destroy) do
|
|
52
|
+
locked_row(:destroy)
|
|
53
|
+
|
|
54
|
+
super()
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def locked_operations
|
|
59
|
+
column = locking_column.to_s
|
|
60
|
+
operations = update_operations.reject { |(_operator, name, _value)| name == column }
|
|
61
|
+
|
|
62
|
+
operations << ['=', column, public_send(locking_column).to_i + 1]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The row, or nil when it is gone; a version other than the record's raises.
|
|
66
|
+
def locked_row(action)
|
|
67
|
+
row = space_storage.get(space_name, key_in_database)
|
|
68
|
+
raise StaleObjectError.new(self, action) if row && row.attributes[locking_column] != public_send(locking_column)
|
|
69
|
+
|
|
70
|
+
row
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Inside an open Model.transaction the check joins it; without transactions (crud) it runs unguarded.
|
|
74
|
+
def atomically(action, &)
|
|
75
|
+
storage = writing_storage
|
|
76
|
+
return yield if Transactions.handle(storage) || !storage.supports_transactions?
|
|
77
|
+
|
|
78
|
+
storage.transaction(&)
|
|
79
|
+
rescue Tarantool::ServerError => e
|
|
80
|
+
raise StaleObjectError.new(self, action) if e.code == ER_TRANSACTION_CONFLICT
|
|
81
|
+
|
|
82
|
+
raise
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def locking_column = self.class.locking_column
|
|
86
|
+
|
|
87
|
+
def locking_enabled? = self.class.locking_enabled?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -80,15 +80,23 @@ module Tarantool
|
|
|
80
80
|
raise ArgumentError, "the primary key of a persisted #{self.class.name} cannot change" if primary_key_changed?
|
|
81
81
|
|
|
82
82
|
run_callbacks(:update) do
|
|
83
|
-
written(updated_row) if
|
|
83
|
+
written(updated_row) if update_pending?
|
|
84
84
|
|
|
85
85
|
true
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
def update_pending? = changed?
|
|
90
|
+
|
|
89
91
|
def updated_row
|
|
90
|
-
|
|
92
|
+
write_row(update_operations)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def update_operations
|
|
96
|
+
changes.map { |name, (_was, value)| ['=', name, value] }
|
|
97
|
+
end
|
|
91
98
|
|
|
99
|
+
def write_row(operations)
|
|
92
100
|
writing_storage.update(space_name, key_in_database, operations) ||
|
|
93
101
|
raise(RecordNotFound.new(self.class, key_in_database))
|
|
94
102
|
end
|
|
@@ -7,13 +7,22 @@ module Tarantool
|
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
9
|
included do
|
|
10
|
-
class_attribute :storage, instance_accessor: false
|
|
11
10
|
class_attribute :primary_key, instance_accessor: false, default: :id
|
|
12
11
|
# @api private
|
|
12
|
+
class_attribute :_storage, instance_accessor: false
|
|
13
|
+
# @api private
|
|
13
14
|
class_attribute :_space_name, instance_accessor: false
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
class_methods do
|
|
18
|
+
def storage
|
|
19
|
+
_storage || Tarantool::Model.default_storage
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def storage=(storage)
|
|
23
|
+
self._storage = storage
|
|
24
|
+
end
|
|
25
|
+
|
|
17
26
|
def space_name
|
|
18
27
|
_space_name || model_name.plural
|
|
19
28
|
end
|
|
@@ -57,6 +57,8 @@ module Tarantool
|
|
|
57
57
|
perform(space, :count) { |table| @mode.scan(table, index:, key:, iterator:, after: nil).size }
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
def supports_transactions? = @mode.supports_transactions?
|
|
61
|
+
|
|
60
62
|
# Writes apply at once; the rows are restored unless the block finishes normally. `isolation:` and
|
|
61
63
|
# `timeout:` are accepted and ignored. Sequences are not restored, as in Tarantool.
|
|
62
64
|
def transaction(**, &)
|
data/lib/tarantool/model.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative 'model/types/binary'
|
|
|
15
15
|
require_relative 'model/settings'
|
|
16
16
|
require_relative 'model/state'
|
|
17
17
|
require_relative 'model/persistence'
|
|
18
|
+
require_relative 'model/locking'
|
|
18
19
|
require_relative 'model/counters'
|
|
19
20
|
require_relative 'model/relation'
|
|
20
21
|
require_relative 'model/querying'
|
|
@@ -27,6 +28,22 @@ module Tarantool
|
|
|
27
28
|
module Model
|
|
28
29
|
extend ActiveSupport::Concern
|
|
29
30
|
|
|
31
|
+
DEFAULT_STORAGE_LOCK = Mutex.new
|
|
32
|
+
private_constant :DEFAULT_STORAGE_LOCK
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Storage for models that set none of their own: a storage, or a Proc called once on first use.
|
|
36
|
+
attr_writer :default_storage
|
|
37
|
+
|
|
38
|
+
def default_storage
|
|
39
|
+
DEFAULT_STORAGE_LOCK.synchronize do
|
|
40
|
+
@default_storage = @default_storage.call if @default_storage.is_a?(Proc)
|
|
41
|
+
|
|
42
|
+
@default_storage
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
30
47
|
include ActiveModel::API
|
|
31
48
|
include ActiveModel::Attributes
|
|
32
49
|
include ActiveModel::Dirty
|
|
@@ -35,6 +52,7 @@ module Tarantool
|
|
|
35
52
|
include Settings
|
|
36
53
|
include State
|
|
37
54
|
include Persistence
|
|
55
|
+
include Locking
|
|
38
56
|
include Counters
|
|
39
57
|
include Transactional
|
|
40
58
|
include Querying
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tarantool-model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Starlexxx
|
|
@@ -60,6 +60,7 @@ files:
|
|
|
60
60
|
- lib/tarantool/model/counters.rb
|
|
61
61
|
- lib/tarantool/model/errors.rb
|
|
62
62
|
- lib/tarantool/model/key.rb
|
|
63
|
+
- lib/tarantool/model/locking.rb
|
|
63
64
|
- lib/tarantool/model/persistence.rb
|
|
64
65
|
- lib/tarantool/model/querying.rb
|
|
65
66
|
- lib/tarantool/model/relation.rb
|