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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b70f00434a73dd90e2c7fcd3b6fa9e73e3e9f2a4809da099d22301d6e64f024
4
- data.tar.gz: cdd55a36929dd8d4a3cb8c10ff4e39e90c1aa304b2ccea431db3354caf80830b
3
+ metadata.gz: dfebd43ac5f258976a8257dae17d23a26a607534c97c83303dc020f8fc7d3e82
4
+ data.tar.gz: 8aee6cf2bfc9c2e16dee2440ebfb4292f6458eeacb355ebe4a40bebdb5d8a43b
5
5
  SHA512:
6
- metadata.gz: 0f63d44c5c2cf9d9ac311efcfe0ee7a46d7819ce4c5681c9200eaaa0116aad14b810b36acdc39480c28cc2fbc9679cedace62acb02cb41da3ee55965d5928ea2
7
- data.tar.gz: 912d7c7254e2c5e9aa4d8e045da95b04c18570eff8b0dd3ee4b8b088ce9837917e11776bfb350e86c648467f7bfba1d7af8627f0bc7dd63701e2ad8d573f1c99
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
- module ApplicationModel
26
- def self.included(base)
27
- base.include Tarantool::Model
28
- base.storage = Tarantool::Model::Storage::Box.new(
29
- pool,
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 ApplicationModel
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
- There is no Railtie. Configure storage in an initializer:
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
- User.storage = Tarantool::Model::Storage::Box.new(pool, read: { mode: :prefer_ro }, write: { mode: :rw })
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 changed?
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
- operations = changes.map { |name, (_was, value)| ['=', name, value] }
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
@@ -54,6 +54,8 @@ module Tarantool
54
54
  raise NotSupportedError, 'Storage::Box does not count'
55
55
  end
56
56
 
57
+ def supports_transactions? = true
58
+
57
59
  def transaction(**options, &block)
58
60
  @caller.transaction(**options) do |stream|
59
61
  Transactions.open(self, stream, &block)
@@ -48,6 +48,8 @@ module Tarantool
48
48
  @crud.space(space).count(conditions(index, key, iterator))
49
49
  end
50
50
 
51
+ def supports_transactions? = false
52
+
51
53
  def transaction(**)
52
54
  raise NotSupportedError, 'crud has no interactive transactions'
53
55
  end
@@ -15,6 +15,8 @@ module Tarantool
15
15
 
16
16
  def transaction! = nil
17
17
 
18
+ def supports_transactions? = true
19
+
18
20
  def get(space, key)
19
21
  space.get(key)
20
22
  end
@@ -23,6 +23,8 @@ module Tarantool
23
23
 
24
24
  def sequences? = false
25
25
 
26
+ def supports_transactions? = false
27
+
26
28
  def get(space, key)
27
29
  parts = space.info.primary_index.parts.size
28
30
  raise Violation.exact_match(parts, key.size) unless key.size == parts
@@ -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(**, &)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tarantool
4
4
  module Model
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -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.2.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