tarantool-rails 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 824fd7e958e18a4576a55a510ae6713557bb7d0e6a36ce202f18d72785eb18ef
4
+ data.tar.gz: e33adb1ed56190a0d11f32c1994a71f2e247c2548e30bb594344b581794f03ba
5
+ SHA512:
6
+ metadata.gz: cd86cbe614985b58a745dfa2807df813e8b1eb95f7ea0a044f26f0a539577c15dac3cbeffa33bcec5774c31583a9a324a7706fbbb72ba16392a4c7e46088bd2f
7
+ data.tar.gz: 5afa1af094609b0ed6f6637b7a1aa08dbe6afcf8b9ae3cda57031ad0f4a3565bb23ff204e37c5e9f779366cc916fdcb83025f8dec3de43501eda8b9ae5f90451
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-17)
4
+
5
+ First release.
6
+
7
+ - `config/tarantool.yml` per environment through `config_for`; `Tarantool::Rails.config`, `pool` (one per
8
+ process), `storage`, `schema`, `admin_client`, `disconnect!`.
9
+ - Models use the configured storage through `Tarantool::Model.default_storage`, built on first use.
10
+ - `Tarantool::Rails::LogSubscriber`: ActiveRecord-style request and lifecycle lines; `ControllerRuntime` adds
11
+ `Tarantool: 1.2ms` to "Completed" lines.
12
+ - Rake tasks `tarantool:schema:plan`, `apply` and `check`.
13
+ - Generators `tarantool:install` and `tarantool:model`.
14
+ - Depends on `railties >= 7.1, < 9`, `tarantool-client ~> 0.2`, `tarantool-model ~> 0.3`, `tarantool-schema ~> 0.1`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Starlexxx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # tarantool-rails
2
+
3
+ Rails integration for [tarantool-client](../tarantool-client), [tarantool-model](../tarantool-model) and
4
+ [tarantool-schema](../tarantool-schema). One gem gives an application `config/tarantool.yml` per environment,
5
+ one connection pool per process, models on that pool without an initializer, ActiveRecord-style request lines
6
+ in the Rails log with a `Tarantool: 1.2ms` share in "Completed" lines, `tarantool:schema:*` rake tasks and
7
+ generators for the configuration and for models.
8
+
9
+ ## Installation
10
+
11
+ Requirements: Ruby >= 3.2, Rails 7.1+, Tarantool 2.11+ or 3.x.
12
+
13
+ ```ruby
14
+ gem 'tarantool-rails'
15
+ gem 'tarantool-crud' # only for storage: crud
16
+ ```
17
+
18
+ ```sh
19
+ rails g tarantool:install # config/tarantool.yml and config/tarantool_schema.rb
20
+ ```
21
+
22
+ ## config/tarantool.yml
23
+
24
+ Read with `config_for`, so ERB, a `shared` section and the environment sections work as in `database.yml`.
25
+
26
+ ```yaml
27
+ default: &default
28
+ user: app
29
+ password: <%= ENV["TARANTOOL_PASSWORD"] %>
30
+ connect_timeout: 3
31
+ request_timeout: 10
32
+
33
+ development:
34
+ <<: *default
35
+ addresses: [tcp://localhost:3301]
36
+ admin_uri: tcp://app:secret@localhost:3301
37
+
38
+ test:
39
+ <<: *default
40
+ addresses: [tcp://localhost:3301]
41
+
42
+ production:
43
+ <<: *default
44
+ addresses:
45
+ - tcp://db1:3301
46
+ - tcp://db2:3301
47
+ read: { mode: prefer_ro }
48
+ write: { mode: rw }
49
+ admin_uri: <%= ENV["TARANTOOL_ADMIN_URI"] %>
50
+ ```
51
+
52
+ | Key | |
53
+ |---|---|
54
+ | `addresses` | required, Array of URIs; `address` with a single String is accepted too |
55
+ | `user`, `password`, `connect_timeout`, `request_timeout`, `reconnect`, `reconnect_attempts`, `reconnect_delay`, `reconnect_max_delay`, `max_frame_size` | passed to `Tarantool::Client::Pool.new` as they are |
56
+ | `read`, `write` | options for `Storage::Box`: `mode` (`prefer_ro`, `rw`, ...) and `timeout` |
57
+ | `storage` | `box` (default) or `crud` |
58
+ | `crud` | for `storage: crud`: `primary_index` (default `primary`), `read`, `write` and `timeout_margin` for `Tarantool::Crud::Client.new` |
59
+ | `admin_uri` | connection for the schema rake tasks, a user that may create spaces |
60
+
61
+ Any other key raises `ArgumentError` at boot. A missing file is not an error at boot, so the install generator
62
+ can run; the first model operation then raises `Tarantool::Rails::Error`.
63
+
64
+ ## What the Railtie does
65
+
66
+ - `Tarantool::Rails.config` holds the environment section as a `Configuration` (its `inspect` hides the
67
+ password).
68
+ - `Tarantool::Rails.pool` is one `Tarantool::Client::Pool` per process, built on first use with
69
+ `instrumenter: ActiveSupport::Notifications`. After `fork` the pool reopens its connections on its own.
70
+ - `Tarantool::Rails.storage` is a `Storage::Box` (or `Storage::Crud`) over the pool, and
71
+ `Tarantool::Model.default_storage` is set so every model without a storage of its own uses it. Nothing
72
+ connects until a model is used.
73
+ - `Tarantool::Rails::LogSubscriber` writes a line per request and `ControllerRuntime` adds the Tarantool time
74
+ to "Completed" lines; turn both off with `config.tarantool.log = false`.
75
+ - `Tarantool::Rails.schema` is the `Tarantool::Schema::Definition` from `config/tarantool_schema.rb`
76
+ (`config.tarantool.schema_path` changes the file).
77
+ - `Tarantool::Rails.disconnect!` closes the pool; the next use builds a new one.
78
+
79
+ ```ruby
80
+ class User
81
+ include Tarantool::Model
82
+
83
+ attribute :id, :integer
84
+ attribute :email, :string
85
+ end
86
+
87
+ User.create!(email: 'a@b.c') # through Tarantool::Rails.storage
88
+ ```
89
+
90
+ ## Log
91
+
92
+ ```
93
+ Tarantool insert users (0.31ms) db1:3301
94
+ Tarantool select users by email (0.18ms) db2:3301
95
+ Tarantool call user.touch (0.42ms) db1:3301
96
+ Tarantool execute (0.57ms) SELECT COUNT(*) FROM "users" db1:3301
97
+ Tarantool update users (0.26ms) db1:3301 Tarantool::ServerError(3)
98
+ Completed 200 OK in 4ms (Views: 1.9ms | Tarantool: 0.5ms)
99
+ ```
100
+
101
+ Request lines are debug, colored like ActiveRecord's when `colorize_logging` is on. Keys, tuples and
102
+ arguments never appear: the client does not put them in the event payload. A failed request shows the error
103
+ class and code, not the message. Lifecycle events are logged too: `disconnect` at warn, `reconnect_failed` at
104
+ error, `reconnect`, `fork` and `role_change` at info.
105
+
106
+ ## Schema
107
+
108
+ `config/tarantool_schema.rb` assigns the definition:
109
+
110
+ ```ruby
111
+ Tarantool::Rails.schema = Tarantool::Schema::Definition.new do
112
+ ensure_space :users,
113
+ format: [{ name: :id, type: :unsigned }, { name: :email, type: :string }],
114
+ indexes: [{ name: :primary, parts: [:id], sequence: true }, { name: :email, parts: [:email] }]
115
+ end
116
+ ```
117
+
118
+ | Task | |
119
+ |---|---|
120
+ | `rails tarantool:schema:plan` | prints what `apply` would create; drift raises `MismatchError` with the diff |
121
+ | `rails tarantool:schema:apply` | creates the missing spaces and indexes and prints them; never alters or drops |
122
+ | `rails tarantool:schema:check` | for CI: exits 1 and prints `pending: ...` when anything would be created, 0 when live equals declared |
123
+
124
+ The tasks connect with `admin_uri`; see the tarantool-schema README for the privileges that user needs.
125
+
126
+ ## Generators
127
+
128
+ ```sh
129
+ rails g tarantool:model User email:string age:unsigned? token:uuid
130
+ ```
131
+
132
+ writes `app/models/user.rb`
133
+
134
+ ```ruby
135
+ class User
136
+ include Tarantool::Model
137
+
138
+ attribute :id, :integer
139
+ attribute :email, :string
140
+ attribute :age, :integer
141
+ attribute :token, Tarantool::Model::Types::UUID.new
142
+ end
143
+ ```
144
+
145
+ and adds the space to `config/tarantool_schema.rb` (skip with `--skip-schema`):
146
+
147
+ ```ruby
148
+ ensure_space :users,
149
+ format: [
150
+ { name: :id, type: :unsigned },
151
+ { name: :email, type: :string },
152
+ { name: :age, type: :unsigned, is_nullable: true },
153
+ { name: :token, type: :uuid }
154
+ ],
155
+ indexes: [
156
+ { name: :primary, parts: [:id], sequence: true }
157
+ ]
158
+ ```
159
+
160
+ `id` is added first as `unsigned` with a sequence unless you list it yourself. A `?` suffix makes the field
161
+ nullable. Types: `integer`, `unsigned`, `string`, `boolean`, `decimal`, `datetime`, `uuid`, `interval`,
162
+ `binary`. `Admin::User` gets the space `admin_users`, the plural tarantool-model uses.
163
+
164
+ ## Testing
165
+
166
+ Point the default storage at tarantool-model's in-memory storage, built from the same declaration the deploy
167
+ applies. No connection is made in the test environment unless something reads `Tarantool::Rails.pool`.
168
+
169
+ ```ruby
170
+ # spec/rails_helper.rb
171
+ require 'tarantool/model/testing'
172
+
173
+ RSpec.configure do |config|
174
+ config.before do
175
+ Tarantool::Model.default_storage = Tarantool::Model::Testing::MemoryStorage.from_definition(Tarantool::Rails.schema)
176
+ end
177
+ end
178
+ ```
179
+
180
+ ## storage: crud
181
+
182
+ ```yaml
183
+ production:
184
+ addresses: [tcp://router1:3301, tcp://router2:3301]
185
+ storage: crud
186
+ crud:
187
+ primary_index: primary
188
+ read: { mode: read, prefer_replica: true }
189
+ ```
190
+
191
+ Models then go through `Tarantool::Model::Storage::Crud` over `Tarantool::Crud::Client`; the `tarantool-crud`
192
+ gem must be in the Gemfile.
193
+
194
+ ## Public API
195
+
196
+ `Tarantool::Rails` (`config`, `pool`, `storage`, `schema`, `admin_client`, `disconnect!`),
197
+ `Tarantool::Rails::Configuration`, `Tarantool::Rails::LogSubscriber`, `Tarantool::Rails::Error`
198
+ (descends from `Tarantool::Error`), the rake tasks and the generators.
199
+
200
+ Everything else, including anything tagged `@api private` in its source comment, is an implementation detail
201
+ and may change in any release.
202
+
203
+ ## Development
204
+
205
+ ```sh
206
+ podman compose -f tarantool-client/docker-compose.yml up -d --wait tarantool-2.11 tarantool-3
207
+ # or: docker compose -f tarantool-client/docker-compose.yml up -d --wait tarantool-2.11 tarantool-3
208
+
209
+ cd tarantool-rails
210
+ bundle exec rspec # unit
211
+ INTEGRATION=1 TARANTOOL_URI=tcp://app:secret@localhost:3301 bundle exec rspec # 2.11
212
+ INTEGRATION=1 TARANTOOL_URI=tcp://app:secret@localhost:3302 bundle exec rspec # 3.x
213
+ ```
214
+
215
+ ## License
216
+
217
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Tarantool
6
+ module Generators
7
+ # rails g tarantool:install
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ def create_configuration
12
+ copy_file 'tarantool.yml', 'config/tarantool.yml'
13
+ end
14
+
15
+ def create_schema
16
+ copy_file 'tarantool_schema.rb', 'config/tarantool_schema.rb'
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ # Every key is documented in the tarantool-rails README. The file goes through ERB like database.yml.
2
+ default: &default
3
+ user: app
4
+ password: <%= ENV["TARANTOOL_PASSWORD"] %>
5
+ connect_timeout: 3
6
+ request_timeout: 10
7
+
8
+ development:
9
+ <<: *default
10
+ addresses: [tcp://localhost:3301]
11
+ admin_uri: <%= ENV.fetch("TARANTOOL_ADMIN_URI", "tcp://app:secret@localhost:3301") %>
12
+
13
+ test:
14
+ <<: *default
15
+ addresses: [tcp://localhost:3301]
16
+
17
+ production:
18
+ <<: *default
19
+ addresses:
20
+ - tcp://db1:3301
21
+ - tcp://db2:3301
22
+ read: { mode: prefer_ro }
23
+ write: { mode: rw }
24
+ admin_uri: <%= ENV["TARANTOOL_ADMIN_URI"] %>
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Applied by `rails tarantool:schema:apply`; `rails g tarantool:model` adds spaces here.
4
+ Tarantool::Rails.schema = Tarantool::Schema::Definition.new do
5
+ # ensure_space :users,
6
+ # format: [{ name: :id, type: :unsigned }, { name: :email, type: :string }],
7
+ # indexes: [{ name: :primary, parts: [:id], sequence: true }, { name: :email, parts: [:email] }]
8
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/named_base'
5
+
6
+ module Tarantool
7
+ module Generators
8
+ # rails g tarantool:model User email:string age:unsigned?
9
+ class ModelGenerator < ::Rails::Generators::NamedBase
10
+ Field = Data.define(:name, :attribute_type, :field_type, :nullable) do
11
+ def declaration
12
+ "{ name: :#{name}, type: :#{field_type}#{', is_nullable: true' if nullable} }"
13
+ end
14
+ end
15
+
16
+ TYPES = {
17
+ 'integer' => [':integer', 'integer'], 'unsigned' => [':integer', 'unsigned'], 'string' => [':string', 'string'],
18
+ 'boolean' => [':boolean', 'boolean'], 'decimal' => [':decimal', 'decimal'],
19
+ 'datetime' => [':datetime', 'datetime'], 'uuid' => ['Tarantool::Model::Types::UUID.new', 'uuid'],
20
+ 'interval' => ['Tarantool::Model::Types::Interval.new', 'interval'],
21
+ 'binary' => ['Tarantool::Model::Types::Binary.new', 'varbinary']
22
+ }.freeze
23
+ ID = Field.new(name: 'id', attribute_type: ':integer', field_type: 'unsigned', nullable: false)
24
+ SCHEMA_PATH = 'config/tarantool_schema.rb'
25
+
26
+ source_root File.expand_path('templates', __dir__)
27
+
28
+ argument :fields, type: :array, default: [], banner: 'name:type name:type?'
29
+ class_option :skip_schema, type: :boolean, default: false, desc: "Do not add the space to #{SCHEMA_PATH}"
30
+
31
+ # Runs before any file is written so an unknown type leaves nothing behind.
32
+ def parse_fields
33
+ model_fields
34
+ end
35
+
36
+ def create_model_file
37
+ template 'model.rb.tt', File.join('app/models', class_path, "#{file_name}.rb")
38
+ end
39
+
40
+ def add_space_to_schema
41
+ return if options[:skip_schema] || !File.exist?(File.join(destination_root, SCHEMA_PATH))
42
+
43
+ inject_into_file SCHEMA_PATH, space_declaration, before: /^end\s*\z/
44
+ end
45
+
46
+ private
47
+
48
+ def model_fields
49
+ @model_fields ||= begin
50
+ declared = fields.map { |field| parse(field) }
51
+ declared.any? { |field| field.name == 'id' } ? declared : [ID, *declared]
52
+ end
53
+ end
54
+
55
+ def parse(field)
56
+ name, type = field.split(':', 2)
57
+ raise Thor::Error, "#{field}: expected name:type" if type.nil? || type.empty?
58
+
59
+ nullable = type.end_with?('?')
60
+ attribute_type, field_type = TYPES[type.delete_suffix('?')]
61
+ raise Thor::Error, "unknown type #{type} for #{name}; use one of #{TYPES.keys.join(', ')}" unless attribute_type
62
+
63
+ Field.new(name:, attribute_type:, field_type:, nullable:)
64
+ end
65
+
66
+ def space_declaration
67
+ sequence = model_fields.first.equal?(ID) ? ', sequence: true' : ''
68
+ lines = ["ensure_space :#{plural_table_name},", ' format: [',
69
+ model_fields.map { |field| " #{field.declaration}" }.join(",\n"),
70
+ ' ],', ' indexes: [',
71
+ " { name: :primary, parts: [:#{model_fields.first.name}]#{sequence} }", ' ]']
72
+
73
+ "\n#{lines.join("\n").gsub(/^/, ' ')}\n"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,9 @@
1
+ <% module_namespacing do -%>
2
+ class <%= class_name %>
3
+ include Tarantool::Model
4
+
5
+ <% model_fields.each do |field| -%>
6
+ attribute :<%= field.name %>, <%= field.attribute_type %>
7
+ <% end -%>
8
+ end
9
+ <% end -%>
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Rails
5
+ # One environment section of config/tarantool.yml, validated and split by consumer: addresses and client
6
+ # options go to the Pool, read/write to Storage::Box, crud to the crud client, admin_uri to the rake tasks.
7
+ Configuration = Data.define(:addresses, :client_options, :storage, :read, :write, :crud, :admin_uri) do
8
+ def inspect
9
+ fields = to_h.map { |key, value| "#{key}=#{filtered(key, value).inspect}" }.join(', ')
10
+
11
+ "#<data #{self.class.name} #{fields}>"
12
+ end
13
+ alias_method :to_s, :inspect
14
+
15
+ private
16
+
17
+ def filtered(key, value)
18
+ case key
19
+ when :client_options then value.key?(:password) ? value.merge(password: '[FILTERED]') : value
20
+ when :admin_uri then value&.sub(%r{:[^:@/]*@}, ':[FILTERED]@')
21
+ else value
22
+ end
23
+ end
24
+ end
25
+
26
+ # Builds a Configuration from the Hash config_for returns.
27
+ class Configuration
28
+ CLIENT_KEYS = %i[user password connect_timeout request_timeout reconnect reconnect_attempts reconnect_delay
29
+ reconnect_max_delay max_frame_size].freeze
30
+ KEYS = (CLIENT_KEYS + %i[addresses address read write storage crud admin_uri]).freeze
31
+ CRUD_KEYS = %i[primary_index read write timeout_margin].freeze
32
+ STORAGES = %i[box crud].freeze
33
+
34
+ class << self
35
+ def from_hash(hash)
36
+ hash = checked(hash.to_h.transform_keys(&:to_sym))
37
+
38
+ new(addresses: addresses(hash), client_options: hash.slice(*CLIENT_KEYS), storage: storage(hash[:storage]),
39
+ read: options(hash[:read]), write: options(hash[:write]), crud: crud(hash[:crud]),
40
+ admin_uri: hash[:admin_uri])
41
+ end
42
+
43
+ private
44
+
45
+ def checked(hash)
46
+ unknown = hash.keys - KEYS
47
+ raise ArgumentError, "unknown key #{unknown.first} in config/tarantool.yml" if unknown.any?
48
+
49
+ hash
50
+ end
51
+
52
+ def addresses(hash)
53
+ addresses = Array(hash[:addresses] || hash[:address])
54
+ raise ArgumentError, 'addresses is required in config/tarantool.yml' if addresses.empty?
55
+
56
+ addresses
57
+ end
58
+
59
+ def storage(value)
60
+ storage = (value || :box).to_sym
61
+ raise ArgumentError, "storage must be box or crud, got #{value.inspect}" unless STORAGES.include?(storage)
62
+
63
+ storage
64
+ end
65
+
66
+ def options(hash)
67
+ options = (hash || {}).to_h.transform_keys(&:to_sym)
68
+ options[:mode] = options[:mode].to_sym if options.key?(:mode)
69
+
70
+ options
71
+ end
72
+
73
+ def crud(hash)
74
+ crud = { primary_index: :primary }.merge((hash || {}).to_h.transform_keys(&:to_sym))
75
+ unknown = crud.keys - CRUD_KEYS
76
+ raise ArgumentError, "unknown key #{unknown.first} in crud" if unknown.any?
77
+
78
+ crud.merge(primary_index: crud[:primary_index].to_sym)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/attr_internal'
4
+
5
+ module Tarantool
6
+ module Rails
7
+ # Adds the time spent in Tarantool requests to the "Completed" line of a controller action and takes it out
8
+ # of the Views share, as ActiveRecord does with db_runtime.
9
+ module ControllerRuntime
10
+ extend ActiveSupport::Concern
11
+
12
+ # @api private
13
+ module ClassMethods
14
+ def log_process_action(payload)
15
+ messages = super
16
+ runtime = payload[:tarantool_runtime]
17
+ messages << format('Tarantool: %.1fms', runtime.to_f) if runtime
18
+
19
+ messages
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ attr_internal :tarantool_runtime
26
+
27
+ def process_action(...)
28
+ LogSubscriber.reset_runtime
29
+
30
+ super
31
+ end
32
+
33
+ def cleanup_view_runtime
34
+ self.tarantool_runtime = tarantool_runtime.to_f + LogSubscriber.reset_runtime
35
+ runtime = super
36
+ during_render = LogSubscriber.reset_runtime
37
+ self.tarantool_runtime += during_render
38
+
39
+ runtime - during_render
40
+ end
41
+
42
+ def append_info_to_payload(payload)
43
+ super
44
+ payload[:tarantool_runtime] = tarantool_runtime.to_f + LogSubscriber.reset_runtime
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Rails
5
+ class Error < Tarantool::Error; end
6
+ end
7
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Rails
5
+ # ActiveRecord-style lines for the client's events, and the time spent in requests per thread so the
6
+ # controller log can show a Tarantool share next to Views.
7
+ class LogSubscriber < ActiveSupport::LogSubscriber
8
+ COLORS = {
9
+ select: MAGENTA, insert: GREEN, replace: GREEN, upsert: GREEN, update: YELLOW, delete: RED,
10
+ begin: CYAN, commit: CYAN, rollback: CYAN
11
+ }.freeze
12
+ PRIMARY = [0, 'primary', :primary].freeze
13
+ RUNTIME = :tarantool_rails_runtime
14
+ private_constant :COLORS, :PRIMARY, :RUNTIME
15
+
16
+ class << self
17
+ def runtime
18
+ ActiveSupport::IsolatedExecutionState[RUNTIME] ||= 0.0
19
+ end
20
+
21
+ def runtime=(value)
22
+ ActiveSupport::IsolatedExecutionState[RUNTIME] = value
23
+ end
24
+
25
+ def reset_runtime
26
+ runtime.tap { self.runtime = 0.0 }
27
+ end
28
+ end
29
+
30
+ def request(event)
31
+ self.class.runtime += event.duration
32
+ return unless logger.debug?
33
+
34
+ payload = event.payload
35
+ name = color(request_name(payload), COLORS.fetch(payload[:request], WHITE), bold: true)
36
+
37
+ debug [name, format('(%.2fms)', event.duration), *request_details(payload)].join(' ')
38
+ end
39
+
40
+ def disconnect(event)
41
+ warn "Tarantool disconnected from #{event.payload[:address]}: #{event.payload[:reason]}"
42
+ end
43
+
44
+ def reconnect(event)
45
+ info "Tarantool reconnected to #{event.payload[:address]} after #{attempts(event.payload[:attempts])}"
46
+ end
47
+
48
+ def reconnect_failed(event)
49
+ error "Tarantool gave up reconnecting to #{event.payload[:address]}: #{event.payload[:exception].join(': ')}"
50
+ end
51
+
52
+ def fork(event)
53
+ info "Tarantool reopened #{event.payload[:address]} after fork"
54
+ end
55
+
56
+ def role_change(event)
57
+ info "Tarantool #{event.payload[:address]} is now #{event.payload[:read_only] ? 'read-only' : 'writable'}"
58
+ end
59
+
60
+ private
61
+
62
+ def request_name(payload)
63
+ parts = ['Tarantool', payload[:request], payload[:space], payload[:function]]
64
+ parts << "by #{payload[:index]}" if payload.key?(:index) && !PRIMARY.include?(payload[:index])
65
+ parts << "##{payload[:statement_id]}" if payload.key?(:statement_id)
66
+
67
+ parts.compact.join(' ')
68
+ end
69
+
70
+ def request_details(payload)
71
+ details = [payload[:sql], payload[:address]]
72
+ details << request_error(payload) if payload.key?(:exception)
73
+ details << 'schema_reloaded' if payload[:schema_reloaded]
74
+
75
+ details.compact
76
+ end
77
+
78
+ # The message may quote data (a duplicate key repeats the tuple); the line keeps the class and code.
79
+ def request_error(payload)
80
+ exception = payload[:exception_object]
81
+ klass = exception ? exception.class.name : payload[:exception].first
82
+ code = exception.respond_to?(:code) ? exception.code : nil
83
+
84
+ code ? "#{klass}(#{code})" : klass
85
+ end
86
+
87
+ def attempts(count)
88
+ count == 1 ? '1 attempt' : "#{count} attempts"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Rails
5
+ # Wires configuration, the default model storage, logging and rake tasks into the application.
6
+ class Railtie < ::Rails::Railtie
7
+ config.tarantool = ActiveSupport::OrderedOptions.new
8
+ config.tarantool.log = true
9
+
10
+ # A missing config/tarantool.yml is not an error at boot: the install generator has to be able to run.
11
+ initializer 'tarantool.configuration' do |app|
12
+ Tarantool::Rails.schema_path = app.config.tarantool.schema_path || app.root.join('config/tarantool_schema.rb')
13
+ next unless app.root.join('config/tarantool.yml').exist?
14
+
15
+ section = app.config_for(:tarantool)
16
+ raise Error, "config/tarantool.yml has no #{::Rails.env} section" unless section
17
+
18
+ Tarantool::Rails.config = Configuration.from_hash(section)
19
+ end
20
+
21
+ initializer 'tarantool.model' do
22
+ Tarantool::Rails.use_default_storage!
23
+ end
24
+
25
+ initializer 'tarantool.log' do |app|
26
+ next unless app.config.tarantool.log
27
+
28
+ LogSubscriber.attach_to :tarantool
29
+ ActiveSupport.on_load(:action_controller) { include ControllerRuntime }
30
+ end
31
+
32
+ rake_tasks { load File.expand_path('tasks/schema.rake', __dir__) }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :tarantool do
4
+ namespace :schema do
5
+ desc 'Print what tarantool:schema:apply would create; fail on drift'
6
+ task plan: :environment do
7
+ Tarantool::Rails.admin_client { |client| puts Tarantool::Rails.schema.plan(client) }
8
+ end
9
+
10
+ desc 'Create missing Tarantool spaces and indexes; fail on drift'
11
+ task apply: :environment do
12
+ Tarantool::Rails.admin_client { |client| puts Tarantool::Rails.schema.apply(client) }
13
+ end
14
+
15
+ desc 'Fail unless the live schema equals the declaration, for CI'
16
+ task check: :environment do
17
+ pending = Tarantool::Rails.admin_client { |client| Tarantool::Rails.schema.plan(client) }
18
+ next if pending.empty?
19
+
20
+ pending.each { |line| puts "pending: #{line}" }
21
+ exit 1
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Rails
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+ require 'tarantool/client'
5
+ require 'tarantool/model'
6
+ require 'tarantool/schema'
7
+
8
+ require_relative 'rails/version'
9
+ require_relative 'rails/errors'
10
+ require_relative 'rails/configuration'
11
+ require_relative 'rails/log_subscriber'
12
+ require_relative 'rails/controller_runtime'
13
+ require_relative 'rails/railtie' if defined?(Rails::Railtie)
14
+
15
+ module Tarantool
16
+ # Rails integration: configuration from config/tarantool.yml, one Pool per process, models on it, log lines,
17
+ # schema rake tasks and generators.
18
+ module Rails
19
+ LOCK = Monitor.new
20
+ private_constant :LOCK
21
+
22
+ class << self
23
+ attr_accessor :config, :schema_path
24
+ attr_writer :schema
25
+
26
+ def pool
27
+ LOCK.synchronize { @pool ||= build_pool }
28
+ end
29
+
30
+ def storage
31
+ LOCK.synchronize { @storage ||= build_storage }
32
+ end
33
+
34
+ def schema
35
+ LOCK.synchronize { @schema ||= load_schema }
36
+ end
37
+
38
+ def admin_client
39
+ uri = configured.admin_uri
40
+ raise Error, 'admin_uri is not set in config/tarantool.yml' unless uri
41
+
42
+ client = Tarantool::Client.new(uri)
43
+
44
+ yield client
45
+ ensure
46
+ client&.close
47
+ end
48
+
49
+ # Models without a storage of their own use the configured one, built on first use.
50
+ def use_default_storage!
51
+ @owns_default_storage = true
52
+ Model.default_storage = -> { storage }
53
+ end
54
+
55
+ def disconnect!
56
+ LOCK.synchronize do
57
+ @pool&.close
58
+ @pool = nil
59
+ @storage = nil
60
+ use_default_storage! if @owns_default_storage
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def configured
67
+ config || raise(Error, 'Tarantool::Rails.config is not set: config/tarantool.yml is missing ' \
68
+ '(rails g tarantool:install) or the Railtie is not loaded')
69
+ end
70
+
71
+ def build_pool
72
+ Tarantool::Client::Pool.new(configured.addresses, **configured.client_options,
73
+ instrumenter: ActiveSupport::Notifications)
74
+ end
75
+
76
+ def build_storage
77
+ configured.storage == :box ? build_box_storage : build_crud_storage
78
+ end
79
+
80
+ def build_box_storage
81
+ Model::Storage::Box.new(pool, read: configured.read, write: configured.write)
82
+ end
83
+
84
+ def build_crud_storage
85
+ require 'tarantool/crud'
86
+ options = configured.crud
87
+ crud = Tarantool::Crud::Client.new(pool, **options.slice(:read, :write, :timeout_margin))
88
+
89
+ Model::Storage::Crud.new(crud, primary_index: options[:primary_index])
90
+ rescue LoadError
91
+ raise Error, 'storage: crud needs the tarantool-crud gem; add it to the Gemfile'
92
+ end
93
+
94
+ def load_schema
95
+ path = schema_path.to_s
96
+ load(path)
97
+
98
+ @schema || raise(Error, "#{path} must assign Tarantool::Rails.schema")
99
+ end
100
+ end
101
+ end
102
+ end
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tarantool-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Starlexxx
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: railties
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.1'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ - !ruby/object:Gem::Dependency
34
+ name: tarantool-client
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.2'
47
+ - !ruby/object:Gem::Dependency
48
+ name: tarantool-model
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.3'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.3'
61
+ - !ruby/object:Gem::Dependency
62
+ name: tarantool-schema
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.1'
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.1'
75
+ description: config/tarantool.yml per environment, one connection pool per process,
76
+ models on it without an initializer, ActiveRecord-style log lines, schema rake tasks
77
+ and generators.
78
+ email:
79
+ - apavlovskiy088@gmail.com
80
+ executables: []
81
+ extensions: []
82
+ extra_rdoc_files: []
83
+ files:
84
+ - CHANGELOG.md
85
+ - LICENSE
86
+ - README.md
87
+ - lib/generators/tarantool/install/install_generator.rb
88
+ - lib/generators/tarantool/install/templates/tarantool.yml
89
+ - lib/generators/tarantool/install/templates/tarantool_schema.rb
90
+ - lib/generators/tarantool/model/model_generator.rb
91
+ - lib/generators/tarantool/model/templates/model.rb.tt
92
+ - lib/tarantool/rails.rb
93
+ - lib/tarantool/rails/configuration.rb
94
+ - lib/tarantool/rails/controller_runtime.rb
95
+ - lib/tarantool/rails/errors.rb
96
+ - lib/tarantool/rails/log_subscriber.rb
97
+ - lib/tarantool/rails/railtie.rb
98
+ - lib/tarantool/rails/tasks/schema.rake
99
+ - lib/tarantool/rails/version.rb
100
+ homepage: https://github.com/Starlexxx/tarantool-ruby
101
+ licenses:
102
+ - MIT
103
+ metadata:
104
+ source_code_uri: https://github.com/Starlexxx/tarantool-ruby/tree/master/tarantool-rails
105
+ changelog_uri: https://github.com/Starlexxx/tarantool-ruby/blob/master/tarantool-rails/CHANGELOG.md
106
+ bug_tracker_uri: https://github.com/Starlexxx/tarantool-ruby/issues
107
+ homepage_uri: https://github.com/Starlexxx/tarantool-ruby
108
+ rubygems_mfa_required: 'true'
109
+ post_install_message:
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '3.2'
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 3.5.22
125
+ signing_key:
126
+ specification_version: 4
127
+ summary: Rails integration for tarantool-client, tarantool-model and tarantool-schema
128
+ test_files: []