mat_views 0.1.2

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.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +121 -0
  4. data/Rakefile +15 -0
  5. data/app/assets/stylesheets/mat_views/application.css +15 -0
  6. data/app/jobs/mat_views/application_job.rb +39 -0
  7. data/app/jobs/mat_views/create_view_job.rb +188 -0
  8. data/app/jobs/mat_views/delete_view_job.rb +196 -0
  9. data/app/jobs/mat_views/refresh_view_job.rb +215 -0
  10. data/app/models/mat_views/application_record.rb +34 -0
  11. data/app/models/mat_views/mat_view_definition.rb +98 -0
  12. data/app/models/mat_views/mat_view_run.rb +89 -0
  13. data/config/routes.rb +12 -0
  14. data/lib/generators/mat_views/install/install_generator.rb +86 -0
  15. data/lib/generators/mat_views/install/templates/create_mat_view_definitions.rb +29 -0
  16. data/lib/generators/mat_views/install/templates/create_mat_view_runs.rb +32 -0
  17. data/lib/generators/mat_views/install/templates/mat_views_initializer.rb +23 -0
  18. data/lib/mat_views/configuration.rb +49 -0
  19. data/lib/mat_views/engine.rb +34 -0
  20. data/lib/mat_views/jobs/adapter.rb +78 -0
  21. data/lib/mat_views/service_response.rb +60 -0
  22. data/lib/mat_views/services/base_service.rb +308 -0
  23. data/lib/mat_views/services/concurrent_refresh.rb +177 -0
  24. data/lib/mat_views/services/create_view.rb +156 -0
  25. data/lib/mat_views/services/delete_view.rb +160 -0
  26. data/lib/mat_views/services/regular_refresh.rb +146 -0
  27. data/lib/mat_views/services/swap_refresh.rb +221 -0
  28. data/lib/mat_views/version.rb +21 -0
  29. data/lib/mat_views.rb +57 -0
  30. data/lib/tasks/helpers.rb +185 -0
  31. data/lib/tasks/mat_views_tasks.rake +145 -0
  32. metadata +95 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ed05279bf4f2812ebeb8cb882f38772636f28c273b0992a878e13fb06b95d17f
4
+ data.tar.gz: 8fa0d71481c042d1b763e012b91dfe397373161decf8a68ef13c27bd3349fb33
5
+ SHA512:
6
+ metadata.gz: 3233a39ea2a44171bc95d15be3bfccf53f507f49efb946787888f3aeae95b94273126331ac607a83e3d9e23c1f8770a4b0362ee2825b6c7ae1c60aab6f9aff92
7
+ data.tar.gz: 05566cd4d75f7ef05465387a39794ca1cae6351375e8b298b7deec0f1503e517d09429f5d1b605042dd6f739c0fa6f868fc839ce600bf201228f206c01dbaec9
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 The mat_views Authors
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,121 @@
1
+ # mat_views (engine)
2
+
3
+ [![Gem](https://img.shields.io/gem/v/mat_views.svg?style=flat-square)](https://rubygems.org/gems/mat_views)
4
+ [![CI](https://github.com/Code-Vedas/rails_materialized_views/actions/workflows/ci.yml/badge.svg)](https://github.com/Code-Vedas/rails_materialized_views/actions/workflows/ci.yml)
5
+ ![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)
6
+
7
+ Rails engine that manages **PostgreSQL materialized views** with definitions, services, background jobs, and Rake tasks.
8
+
9
+ ---
10
+
11
+ ## Quickstart (diagram)
12
+
13
+ ```mermaid
14
+ flowchart LR
15
+ A[Define MatViewDefinition] --> B[Create]
16
+ B --> C[Unique Index for CONCURRENT]
17
+ C --> D[Refresh: regular or concurrent or swap]
18
+ D --> E[Read MV]
19
+ D --> F[Track runs]
20
+ ```
21
+
22
+ ---
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ bundle add mat_views
28
+ bin/rails g mat_views:install
29
+ bin/rails db:migrate
30
+ ```
31
+
32
+ ```ruby
33
+ # config/initializers/mat_views.rb
34
+ MatViews.configure do |c|
35
+ c.job_queue = :default
36
+ end
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Define a view
42
+
43
+ ```ruby
44
+ defn = MatViews::MatViewDefinition.create!(
45
+ name: 'mv_user_activity',
46
+ sql: <<~SQL,
47
+ SELECT u.id AS user_id,
48
+ COUNT(a.*) AS accounts_count,
49
+ COUNT(e.*) AS events_count,
50
+ COUNT(s.*) AS sessions_count
51
+ FROM users u
52
+ LEFT JOIN accounts a ON a.user_id = u.id
53
+ LEFT JOIN events e ON e.user_id = u.id
54
+ LEFT JOIN sessions s ON s.user_id = u.id
55
+ GROUP BY u.id
56
+ SQL
57
+ refresh_strategy: :concurrent,
58
+ unique_index_columns: ['user_id']
59
+ )
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Services & Jobs
65
+
66
+ ```ruby
67
+ # Create
68
+ MatViews::Services::CreateView.new(defn, force: true).run
69
+ MatViews::CreateViewJob.perform_later(defn.id, force: true)
70
+
71
+ # Refresh
72
+ MatViews::Services::RegularRefresh.new(defn, row_count_strategy: :estimated).run
73
+ MatViews::RefreshViewJob.perform_later(defn.id, row_count_strategy: :exact)
74
+
75
+ # Delete
76
+ MatViews::Services::DeleteView.new(defn, cascade: false, if_exists: true).run
77
+ MatViews::DeleteViewJob.perform_later(defn.id, cascade: true)
78
+ ```
79
+
80
+ **Uniform response**: `status`, `payload`, `meta`, `success?` / `error?`.
81
+
82
+ ---
83
+
84
+ ## Enqueue adapter
85
+
86
+ ```ruby
87
+ MatViews::Jobs::Adapter.enqueue(job_class, queue: :default, args: [...])
88
+ ```
89
+
90
+ - Uses your configured backend; **no guessing**.
91
+ - Supports **ActiveJob**, **Sidekiq**, **Resque**.
92
+
93
+ ---
94
+
95
+ ## Rake tasks
96
+
97
+ ```bash
98
+ # Create
99
+ bundle exec rake mat_views:create_by_name\[VIEW_NAME,force,--yes]
100
+ bundle exec rake mat_views:create_by_id\[ID,force,--yes]
101
+ bundle exec rake mat_views:create_all\[force,--yes]
102
+
103
+ # Refresh
104
+ bundle exec rake mat_views:refresh_by_name\[VIEW_NAME,row_count_strategy,--yes]
105
+ bundle exec rake mat_views:refresh_by_id\[ID,row_count_strategy,--yes]
106
+ bundle exec rake mat_views:refresh_all\[row_count_strategy,--yes]
107
+
108
+ # Delete
109
+ bundle exec rake mat_views:delete_by_name\[VIEW_NAME,cascade,--yes]
110
+ bundle exec rake mat_views:delete_by_id\[ID,cascade,--yes]
111
+ bundle exec rake mat_views:delete_all\[cascade,--yes]
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Docs & policies
117
+
118
+ - Root README: [../README.md](../README.md)
119
+ - **Contributing:** [../CONTRIBUTING.md](../CONTRIBUTING.md)
120
+ - **Security policy:** [../SECURITY.md](../SECURITY.md)
121
+ - **Code of Conduct:** [../CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md)
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ require 'bundler/setup'
9
+
10
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
11
+ load 'rails/tasks/engine.rake'
12
+
13
+ load 'rails/tasks/statistics.rake'
14
+
15
+ require 'bundler/gem_tasks'
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ ##
9
+ # Top-level namespace for the mat_views engine.
10
+ #
11
+ # All classes, modules, and services for materialized view management
12
+ # are defined under this namespace.
13
+ #
14
+ # @example Accessing a job
15
+ # MatViews::ApplicationJob
16
+ #
17
+ module MatViews
18
+ ##
19
+ # Base class for all background jobs in the mat_views engine.
20
+ #
21
+ # Inherits from {ActiveJob::Base} and provides a common superclass
22
+ # for engine jobs such as {MatViews::CreateViewJob} and {MatViews::RefreshViewJob}.
23
+ #
24
+ # @abstract
25
+ #
26
+ # @see MatViews::CreateViewJob
27
+ # @see MatViews::RefreshViewJob
28
+ # @see MatViews::DeleteViewJob
29
+ #
30
+ # @example Defining a custom job
31
+ # class MyCustomJob < MatViews::ApplicationJob
32
+ # def perform(definition_id)
33
+ # # custom logic here
34
+ # end
35
+ # end
36
+ #
37
+ class ApplicationJob < ActiveJob::Base
38
+ end
39
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ ##
9
+ # Top-level namespace for the mat_views engine.
10
+ module MatViews
11
+ ##
12
+ # ActiveJob that handles *creation* of PostgreSQL materialized views for a
13
+ # given {MatViews::MatViewDefinition}.
14
+ #
15
+ # The job:
16
+ # 1. Normalizes the `force` argument.
17
+ # 2. Looks up the target {MatViews::MatViewDefinition}.
18
+ # 3. Starts a {MatViews::MatViewRun} row to track lifecycle/timing, with `operation: :create`.
19
+ # 4. Executes {MatViews::Services::CreateView}.
20
+ # 5. Finalizes the run with success/failure, duration, and payload meta.
21
+ #
22
+ # @see MatViews::Services::CreateView
23
+ # @see MatViews::MatViewDefinition
24
+ # @see MatViews::MatViewRun
25
+ #
26
+ # @example Enqueue a create job
27
+ # MatViews::CreateViewJob.perform_later(definition.id, force: true)
28
+ #
29
+ # @example Inline run (test/dev)
30
+ # MatViews::CreateViewJob.new.perform(definition.id, false)
31
+ #
32
+ class CreateViewJob < ::ActiveJob::Base
33
+ ##
34
+ # Queue name for the job.
35
+ #
36
+ # Uses `MatViews.configuration.job_queue` when configured, otherwise `:default`.
37
+ #
38
+ # @return [void]
39
+ #
40
+ queue_as { MatViews.configuration.job_queue || :default }
41
+
42
+ ##
43
+ # Perform the job for the given materialized view definition.
44
+ #
45
+ # @api public
46
+ #
47
+ # @param definition_id [Integer, String] ID of {MatViews::MatViewDefinition}.
48
+ # @param force_arg [Boolean, Hash, nil] Optional flag or hash (`{ force: true }`)
49
+ # to force creation (drop/recreate) when supported by the service.
50
+ #
51
+ # @return [Hash] A serialized {MatViews::ServiceResponse#to_h}:
52
+ # - `:status` [Symbol] one of `:ok, :created, :updated, :noop, :error`
53
+ # - `:payload` [Hash] service-specific payload (also stored in run.meta)
54
+ # - `:error` [String, nil] error message if any
55
+ # - `:duration_ms` [Integer, nil]
56
+ # - `:meta` [Hash]
57
+ #
58
+ # @raise [StandardError] Re-raised on unexpected failure after marking the run failed.
59
+ #
60
+ # @see MatViews::Services::CreateView
61
+ #
62
+ def perform(definition_id, force_arg = nil)
63
+ force = normalize_force(force_arg)
64
+
65
+ definition = MatViews::MatViewDefinition.find(definition_id)
66
+ run = start_run(definition)
67
+
68
+ response, duration_ms = execute(definition, force: force)
69
+ finalize_run!(run, response, duration_ms)
70
+ response.to_h
71
+ rescue StandardError => e
72
+ fail_run!(run, e) if run
73
+ raise e
74
+ end
75
+
76
+ private
77
+
78
+ ##
79
+ # Normalize the `force` argument into a boolean.
80
+ #
81
+ # Accepts either a boolean-ish value or a Hash (e.g., `{ force: true }` or `{ "force" => true }`).
82
+ #
83
+ # @api private
84
+ #
85
+ # @param arg [Object] Raw argument; commonly `true/false`, `nil`, or `Hash`.
86
+ # @return [Boolean] Coerced force flag.
87
+ #
88
+ def normalize_force(arg)
89
+ case arg
90
+ when Hash
91
+ arg[:force] || arg['force'] || false
92
+ else
93
+ !!arg
94
+ end
95
+ end
96
+
97
+ ##
98
+ # Execute the create service and measure duration.
99
+ #
100
+ # @api private
101
+ #
102
+ # @param definition [MatViews::MatViewDefinition]
103
+ # @param force [Boolean]
104
+ # @return [Array(MatViews::ServiceResponse, Integer)] response and elapsed ms.
105
+ #
106
+ def execute(definition, force:)
107
+ started = monotime
108
+ response = MatViews::Services::CreateView.new(definition, force: force).run
109
+ [response, elapsed_ms(started)]
110
+ end
111
+
112
+ ##
113
+ # Begin a {MatViews::MatViewRun} row for lifecycle tracking.
114
+ #
115
+ # @api private
116
+ #
117
+ # @param definition [MatViews::MatViewDefinition]
118
+ # @return [MatViews::MatViewRun] newly created run with `status: :running`
119
+ #
120
+ def start_run(definition)
121
+ MatViews::MatViewRun.create!(
122
+ mat_view_definition: definition,
123
+ status: :running,
124
+ started_at: Time.current,
125
+ operation: :create
126
+ )
127
+ end
128
+
129
+ ##
130
+ # Finalize the run with success/failure, timing, and meta from the response payload.
131
+ #
132
+ # @api private
133
+ #
134
+ # @param run [MatViews::MatViewRun]
135
+ # @param response [MatViews::ServiceResponse]
136
+ # @param duration_ms [Integer]
137
+ # @return [void]
138
+ #
139
+ def finalize_run!(run, response, duration_ms)
140
+ base_attrs = {
141
+ finished_at: Time.current,
142
+ duration_ms: duration_ms,
143
+ meta: response.payload || {}
144
+ }
145
+
146
+ if response.success?
147
+ run.update!(base_attrs.merge(status: :success, error: nil))
148
+ else
149
+ run.update!(base_attrs.merge(status: :failed, error: response.error.to_s.presence))
150
+ end
151
+ end
152
+
153
+ ##
154
+ # Mark the run failed due to an exception.
155
+ #
156
+ # @api private
157
+ #
158
+ # @param run [MatViews::MatViewRun]
159
+ # @param exception [Exception]
160
+ # @return [void]
161
+ #
162
+ def fail_run!(run, exception)
163
+ run.update!(
164
+ finished_at: Time.current,
165
+ duration_ms: run.duration_ms || 0,
166
+ error: "#{exception.class}: #{exception.message}",
167
+ status: :failed
168
+ )
169
+ end
170
+
171
+ ##
172
+ # Monotonic clock getter (for elapsed-time measurement).
173
+ #
174
+ # @api private
175
+ # @return [Float] seconds from a monotonic source.
176
+ #
177
+ def monotime = Process.clock_gettime(Process::CLOCK_MONOTONIC)
178
+
179
+ ##
180
+ # Convert a monotonic start time to elapsed milliseconds.
181
+ #
182
+ # @api private
183
+ # @param start [Float] monotonic seconds.
184
+ # @return [Integer] elapsed milliseconds.
185
+ #
186
+ def elapsed_ms(start) = ((monotime - start) * 1000).round
187
+ end
188
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ ##
9
+ # Top-level namespace for the mat_views engine.
10
+ module MatViews
11
+ ##
12
+ # ActiveJob that handles *deletion* of PostgreSQL materialized views via
13
+ # {MatViews::Services::DeleteView}.
14
+ #
15
+ # This job mirrors {MatViews::CreateViewJob} and {MatViews::RefreshViewJob}:
16
+ # it times the run and persists lifecycle state in {MatViews::MatViewRun}.
17
+ #
18
+ # @see MatViews::Services::DeleteView
19
+ # @see MatViews::MatViewDefinition
20
+ # @see MatViews::MatViewRun
21
+ #
22
+ # @example Enqueue a delete job
23
+ # MatViews::DeleteViewJob.perform_later(definition.id, cascade: true)
24
+ #
25
+ # @example Inline run (test/dev)
26
+ # MatViews::DeleteViewJob.new.perform(definition.id, false)
27
+ #
28
+ class DeleteViewJob < ::ActiveJob::Base
29
+ ##
30
+ # Queue name for the job.
31
+ #
32
+ # Uses `MatViews.configuration.job_queue` when configured, otherwise `:default`.
33
+ #
34
+ queue_as { MatViews.configuration.job_queue || :default }
35
+
36
+ ##
37
+ # Perform the job for the given materialized view definition.
38
+ #
39
+ # @api public
40
+ #
41
+ # @param definition_id [Integer, String] ID of {MatViews::MatViewDefinition}.
42
+ # @param cascade_arg [Boolean, String, Integer, Hash, nil] Cascade option.
43
+ # Accepts:
44
+ # - `true/false`
45
+ # - `1` (treated as true)
46
+ # - `"true"`, `"1"`, `"yes"` (case-insensitive)
47
+ # - `{ cascade: true }` or `{ "cascade" => true }`
48
+ #
49
+ # @return [Hash] A serialized {MatViews::ServiceResponse#to_h}:
50
+ # - `:status` [Symbol] `:success`, `:failed`, etc.
51
+ # - `:payload` [Hash] response payload (also stored in run.meta)
52
+ # - `:error` [String, nil]
53
+ # - `:duration_ms` [Integer]
54
+ # - `:meta` [Hash]
55
+ #
56
+ # @raise [StandardError] Re-raised on unexpected failure after marking the run failed.
57
+ #
58
+ def perform(definition_id, cascade_arg = nil)
59
+ cascade = normalize_cascade?(cascade_arg)
60
+ definition = MatViews::MatViewDefinition.find(definition_id)
61
+ run = start_run(definition)
62
+
63
+ response, duration_ms = execute(definition, cascade: cascade)
64
+ finalize_run!(run, response, duration_ms)
65
+ response.to_h
66
+ rescue StandardError => e
67
+ fail_run!(run, e) if run
68
+ raise e
69
+ end
70
+
71
+ private
72
+
73
+ ##
74
+ # Normalize cascade argument into a boolean.
75
+ #
76
+ # @api private
77
+ # @param arg [Object] Raw cascade argument.
78
+ # @return [Boolean] Whether to cascade drop.
79
+ #
80
+ def normalize_cascade?(arg)
81
+ value = if arg.is_a?(Hash)
82
+ arg[:cascade] || arg['cascade']
83
+ else
84
+ arg
85
+ end
86
+ cascade_value_trueish?(value)
87
+ end
88
+
89
+ ##
90
+ # Evaluate if a value is "truthy" for cascade purposes.
91
+ #
92
+ # @api private
93
+ # @param value [Object]
94
+ # @return [Boolean]
95
+ #
96
+ def cascade_value_trueish?(value)
97
+ case value
98
+ when true
99
+ true
100
+ when String
101
+ %w[true 1 yes].include?(value.strip.downcase)
102
+ when Integer
103
+ value == 1
104
+ else
105
+ false
106
+ end
107
+ end
108
+
109
+ ##
110
+ # Execute the delete service and measure duration.
111
+ #
112
+ # @api private
113
+ # @param definition [MatViews::MatViewDefinition]
114
+ # @param cascade [Boolean]
115
+ # @return [Array(MatViews::ServiceResponse, Integer)]
116
+ #
117
+ def execute(definition, cascade:)
118
+ started = monotime
119
+ response = MatViews::Services::DeleteView.new(definition, cascade: cascade, if_exists: true).run
120
+ [response, elapsed_ms(started)]
121
+ end
122
+
123
+ ##
124
+ # Begin a {MatViews::MatViewRun} row for lifecycle tracking.
125
+ #
126
+ # @api private
127
+ # @param definition [MatViews::MatViewDefinition]
128
+ # @return [MatViews::MatViewRun]
129
+ #
130
+ def start_run(definition)
131
+ MatViews::MatViewRun.create!(
132
+ mat_view_definition: definition,
133
+ status: :running,
134
+ started_at: Time.current,
135
+ operation: :drop
136
+ )
137
+ end
138
+
139
+ ##
140
+ # Finalize the run with success/failure, timing, and meta from the response.
141
+ #
142
+ # @api private
143
+ # @param run [MatViews::MatViewRun]
144
+ # @param response [MatViews::ServiceResponse]
145
+ # @param duration_ms [Integer]
146
+ # @return [void]
147
+ #
148
+ def finalize_run!(run, response, duration_ms)
149
+ base_attrs = {
150
+ finished_at: Time.current,
151
+ duration_ms: duration_ms,
152
+ meta: response.payload || {}
153
+ }
154
+
155
+ if response.success?
156
+ run.update!(base_attrs.merge(status: :success, error: nil))
157
+ else
158
+ run.update!(base_attrs.merge(status: :failed, error: response.error.to_s.presence))
159
+ end
160
+ end
161
+
162
+ ##
163
+ # Mark the run failed due to an exception.
164
+ #
165
+ # @api private
166
+ # @param run [MatViews::MatViewRun]
167
+ # @param exception [Exception]
168
+ # @return [void]
169
+ #
170
+ def fail_run!(run, exception)
171
+ run.update!(
172
+ finished_at: Time.current,
173
+ duration_ms: run.duration_ms || 0,
174
+ error: "#{exception.class}: #{exception.message}",
175
+ status: :failed
176
+ )
177
+ end
178
+
179
+ ##
180
+ # Monotonic clock getter (for elapsed-time measurement).
181
+ #
182
+ # @api private
183
+ # @return [Float] seconds
184
+ #
185
+ def monotime = Process.clock_gettime(Process::CLOCK_MONOTONIC)
186
+
187
+ ##
188
+ # Convert monotonic start time to elapsed milliseconds.
189
+ #
190
+ # @api private
191
+ # @param start [Float]
192
+ # @return [Integer] elapsed ms
193
+ #
194
+ def elapsed_ms(start) = ((monotime - start) * 1000).round
195
+ end
196
+ end