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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +121 -0
- data/Rakefile +15 -0
- data/app/assets/stylesheets/mat_views/application.css +15 -0
- data/app/jobs/mat_views/application_job.rb +39 -0
- data/app/jobs/mat_views/create_view_job.rb +188 -0
- data/app/jobs/mat_views/delete_view_job.rb +196 -0
- data/app/jobs/mat_views/refresh_view_job.rb +215 -0
- data/app/models/mat_views/application_record.rb +34 -0
- data/app/models/mat_views/mat_view_definition.rb +98 -0
- data/app/models/mat_views/mat_view_run.rb +89 -0
- data/config/routes.rb +12 -0
- data/lib/generators/mat_views/install/install_generator.rb +86 -0
- data/lib/generators/mat_views/install/templates/create_mat_view_definitions.rb +29 -0
- data/lib/generators/mat_views/install/templates/create_mat_view_runs.rb +32 -0
- data/lib/generators/mat_views/install/templates/mat_views_initializer.rb +23 -0
- data/lib/mat_views/configuration.rb +49 -0
- data/lib/mat_views/engine.rb +34 -0
- data/lib/mat_views/jobs/adapter.rb +78 -0
- data/lib/mat_views/service_response.rb +60 -0
- data/lib/mat_views/services/base_service.rb +308 -0
- data/lib/mat_views/services/concurrent_refresh.rb +177 -0
- data/lib/mat_views/services/create_view.rb +156 -0
- data/lib/mat_views/services/delete_view.rb +160 -0
- data/lib/mat_views/services/regular_refresh.rb +146 -0
- data/lib/mat_views/services/swap_refresh.rb +221 -0
- data/lib/mat_views/version.rb +21 -0
- data/lib/mat_views.rb +57 -0
- data/lib/tasks/helpers.rb +185 -0
- data/lib/tasks/mat_views_tasks.rake +145 -0
- metadata +95 -0
@@ -0,0 +1,34 @@
|
|
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
|
+
module MatViews
|
9
|
+
##
|
10
|
+
# Rails Engine for MatViews.
|
11
|
+
#
|
12
|
+
# This engine encapsulates all functionality related to
|
13
|
+
# materialized views, including:
|
14
|
+
# - Defining materialized view definitions
|
15
|
+
# - Creating and refreshing views
|
16
|
+
# - Managing background jobs for refresh/create/delete
|
17
|
+
#
|
18
|
+
# By isolating the namespace, it ensures that routes, models,
|
19
|
+
# and helpers do not conflict with the host application.
|
20
|
+
#
|
21
|
+
# @example Mounting the engine in a Rails application
|
22
|
+
# # config/routes.rb
|
23
|
+
# Rails.application.routes.draw do
|
24
|
+
# mount MatViews::Engine => "/mat_views"
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
class Engine < ::Rails::Engine
|
28
|
+
isolate_namespace MatViews
|
29
|
+
|
30
|
+
initializer 'mat_views.load_config' do
|
31
|
+
MatViews.configuration ||= MatViews::Configuration.new
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,78 @@
|
|
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
|
+
# Namespace for job-related utilities and integrations.
|
13
|
+
module Jobs
|
14
|
+
##
|
15
|
+
# Adapter class for handling job enqueuing across different backends.
|
16
|
+
#
|
17
|
+
# This class abstracts the job enqueueing process so MatViews can work
|
18
|
+
# with multiple background processing frameworks without changing core code.
|
19
|
+
#
|
20
|
+
# Supported adapters (configured via `MatViews.configuration.job_adapter`):
|
21
|
+
# - `:active_job` → {ActiveJob}
|
22
|
+
# - `:sidekiq` → {Sidekiq::Client}
|
23
|
+
# - `:resque` → {Resque}
|
24
|
+
#
|
25
|
+
# @example Enqueue via ActiveJob
|
26
|
+
# MatViews.configuration.job_adapter = :active_job
|
27
|
+
# MatViews::Jobs::Adapter.enqueue(MyJob, queue: :low, args: [1, "foo"])
|
28
|
+
#
|
29
|
+
# @example Enqueue via Sidekiq
|
30
|
+
# MatViews.configuration.job_adapter = :sidekiq
|
31
|
+
# MatViews::Jobs::Adapter.enqueue(MyWorker, queue: :critical, args: [42])
|
32
|
+
#
|
33
|
+
# @example Enqueue via Resque
|
34
|
+
# MatViews.configuration.job_adapter = :resque
|
35
|
+
# MatViews::Jobs::Adapter.enqueue(MyWorker, queue: :default, args: %w[a b c])
|
36
|
+
#
|
37
|
+
# @raise [ArgumentError] if the configured adapter is not recognized
|
38
|
+
#
|
39
|
+
class Adapter
|
40
|
+
##
|
41
|
+
# Enqueue a job across supported backends.
|
42
|
+
#
|
43
|
+
# @api public
|
44
|
+
#
|
45
|
+
# @param job_class [Class] The job or worker class to enqueue.
|
46
|
+
# - For `:active_job`, this should be a subclass of {ActiveJob::Base}.
|
47
|
+
# - For `:sidekiq`, this should be a Sidekiq worker class.
|
48
|
+
# - For `:resque`, this should be a Resque worker class.
|
49
|
+
#
|
50
|
+
# @param queue [String, Symbol] Target queue name.
|
51
|
+
# @param args [Array] Arguments to pass into the job/worker.
|
52
|
+
#
|
53
|
+
# @return [Object] Framework-dependent:
|
54
|
+
# - For ActiveJob → enqueued {ActiveJob::Base} instance
|
55
|
+
# - For Sidekiq → job ID hash
|
56
|
+
# - For Resque → `true` if enqueue succeeded
|
57
|
+
#
|
58
|
+
# @raise [ArgumentError] if the configured adapter is not recognized.
|
59
|
+
#
|
60
|
+
def self.enqueue(job_class, queue:, args: [])
|
61
|
+
case MatViews.configuration.job_adapter
|
62
|
+
when :active_job
|
63
|
+
job_class.set(queue: queue.to_s).perform_later(*args)
|
64
|
+
when :sidekiq
|
65
|
+
Sidekiq::Client.push(
|
66
|
+
'class' => job_class.name,
|
67
|
+
'queue' => queue.to_s,
|
68
|
+
'args' => args
|
69
|
+
)
|
70
|
+
when :resque
|
71
|
+
Resque.enqueue_to(queue.to_s, job_class, *args)
|
72
|
+
else
|
73
|
+
raise ArgumentError, "Unknown job adapter: #{MatViews.configuration.job_adapter.inspect}"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,60 @@
|
|
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
|
+
module MatViews
|
9
|
+
##
|
10
|
+
# Encapsulates the result of a service operation within MatViews.
|
11
|
+
#
|
12
|
+
# Provides a consistent contract for all services by standardizing:
|
13
|
+
# - `status`: Symbol representing outcome (`:ok`, `:created`, `:updated`, `:noop`,
|
14
|
+
# `:skipped`, `:deleted`, `:error`)
|
15
|
+
# - `payload`: Arbitrary structured data returned by the service
|
16
|
+
# - `error`: Exception object or message if an error occurred
|
17
|
+
# - `meta`: Additional metadata such as SQL statements, timing, or strategies
|
18
|
+
#
|
19
|
+
# @example Successful response
|
20
|
+
# MatViews::ServiceResponse.new(
|
21
|
+
# status: :updated,
|
22
|
+
# payload: { view: "public.users_mv" }
|
23
|
+
# )
|
24
|
+
#
|
25
|
+
# @example Error response
|
26
|
+
# MatViews::ServiceResponse.new(
|
27
|
+
# status: :error,
|
28
|
+
# error: StandardError.new("Something went wrong")
|
29
|
+
# )
|
30
|
+
#
|
31
|
+
class ServiceResponse
|
32
|
+
attr_reader :status, :payload, :error, :meta
|
33
|
+
|
34
|
+
# @param status [Symbol] the outcome status
|
35
|
+
# @param payload [Hash] optional data payload
|
36
|
+
# @param error [Exception, String, nil] error details if applicable
|
37
|
+
# @param meta [Hash] additional metadata
|
38
|
+
def initialize(status:, payload: {}, error: nil, meta: {})
|
39
|
+
@status = status.to_sym
|
40
|
+
@payload = payload
|
41
|
+
@error = error
|
42
|
+
@meta = meta
|
43
|
+
end
|
44
|
+
|
45
|
+
# @return [Boolean] whether the response represents a success
|
46
|
+
def success?
|
47
|
+
!error? && %i[ok created updated noop skipped deleted].include?(status)
|
48
|
+
end
|
49
|
+
|
50
|
+
# @return [Boolean] whether the response represents an error
|
51
|
+
def error?
|
52
|
+
!error.nil? || status == :error
|
53
|
+
end
|
54
|
+
|
55
|
+
# @return [Hash] hash representation of the response
|
56
|
+
def to_h
|
57
|
+
{ status:, payload:, error:, meta: }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,308 @@
|
|
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
|
+
module MatViews
|
9
|
+
module Services
|
10
|
+
##
|
11
|
+
# Base class for service objects that operate on PostgreSQL materialized
|
12
|
+
# views (create/refresh/delete, schema discovery, quoting, and common
|
13
|
+
# response helpers).
|
14
|
+
#
|
15
|
+
# Concrete services (e.g., {MatViews::Services::CreateView},
|
16
|
+
# {MatViews::Services::RegularRefresh}) should inherit from this class.
|
17
|
+
#
|
18
|
+
# @abstract
|
19
|
+
#
|
20
|
+
# @example Subclassing BaseService
|
21
|
+
# class MyService < MatViews::Services::BaseService
|
22
|
+
# def run
|
23
|
+
# return err("missing view") unless view_exists?
|
24
|
+
# # perform work...
|
25
|
+
# ok(:updated, payload: { view: "#{schema}.#{rel}" })
|
26
|
+
# rescue => e
|
27
|
+
# error_response(e, meta: { op: "my_service" })
|
28
|
+
# end
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
class BaseService
|
32
|
+
##
|
33
|
+
# @return [MatViews::MatViewDefinition] The target materialized view definition.
|
34
|
+
attr_reader :definition
|
35
|
+
|
36
|
+
##
|
37
|
+
# @param definition [MatViews::MatViewDefinition]
|
38
|
+
def initialize(definition)
|
39
|
+
@definition = definition
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
# ────────────────────────────────────────────────────────────────
|
45
|
+
# Schema / resolution helpers
|
46
|
+
# ────────────────────────────────────────────────────────────────
|
47
|
+
|
48
|
+
##
|
49
|
+
# Resolve the first existing schema from `schema_search_path`,
|
50
|
+
# falling back to `"public"` if none are valid.
|
51
|
+
#
|
52
|
+
# Supports `$user`, quoted tokens, and ignores non-existent schemas.
|
53
|
+
#
|
54
|
+
# @api private
|
55
|
+
# @return [String] a valid schema name
|
56
|
+
#
|
57
|
+
def first_existing_schema
|
58
|
+
raw_path = conn.schema_search_path.presence || 'public'
|
59
|
+
candidates = raw_path.split(',').filter_map { |t| resolve_schema_token(t.strip) }
|
60
|
+
candidates << 'public' unless candidates.include?('public')
|
61
|
+
candidates.find { |s| schema_exists?(s) } || 'public'
|
62
|
+
end
|
63
|
+
|
64
|
+
##
|
65
|
+
# Normalize a schema token:
|
66
|
+
# - strip surrounding quotes
|
67
|
+
# - expand `$user` to the current database user
|
68
|
+
#
|
69
|
+
# @api private
|
70
|
+
# @param token [String]
|
71
|
+
# @return [String]
|
72
|
+
#
|
73
|
+
def resolve_schema_token(token)
|
74
|
+
cleaned = token.delete_prefix('"').delete_suffix('"')
|
75
|
+
return current_user if cleaned == '$user'
|
76
|
+
|
77
|
+
cleaned
|
78
|
+
end
|
79
|
+
|
80
|
+
##
|
81
|
+
# @api private
|
82
|
+
# @return [String] current PostgreSQL user
|
83
|
+
def current_user
|
84
|
+
@current_user ||= conn.select_value('SELECT current_user')
|
85
|
+
end
|
86
|
+
|
87
|
+
##
|
88
|
+
# Check whether a schema exists.
|
89
|
+
#
|
90
|
+
# @api private
|
91
|
+
# @param name [String] schema name
|
92
|
+
# @return [Boolean]
|
93
|
+
#
|
94
|
+
def schema_exists?(name)
|
95
|
+
conn.select_value("SELECT to_regnamespace(#{conn.quote(name)}) IS NOT NULL")
|
96
|
+
end
|
97
|
+
|
98
|
+
# ────────────────────────────────────────────────────────────────
|
99
|
+
# View / relation helpers
|
100
|
+
# ────────────────────────────────────────────────────────────────
|
101
|
+
|
102
|
+
##
|
103
|
+
# Whether the materialized view exists for the resolved `schema` and `rel`.
|
104
|
+
#
|
105
|
+
# @api private
|
106
|
+
# @return [Boolean]
|
107
|
+
#
|
108
|
+
def view_exists?
|
109
|
+
conn.select_value(<<~SQL).to_i.positive?
|
110
|
+
SELECT COUNT(*)
|
111
|
+
FROM pg_matviews
|
112
|
+
WHERE schemaname = #{conn.quote(schema)}
|
113
|
+
AND matviewname = #{conn.quote(rel)}
|
114
|
+
SQL
|
115
|
+
end
|
116
|
+
|
117
|
+
##
|
118
|
+
# Fully-qualified, safely-quoted relation name, e.g. `"public"."mv_users"`.
|
119
|
+
#
|
120
|
+
# @api private
|
121
|
+
# @return [String]
|
122
|
+
#
|
123
|
+
def qualified_rel
|
124
|
+
%(#{quote_table_name(schema)}.#{quote_table_name(rel)})
|
125
|
+
end
|
126
|
+
|
127
|
+
##
|
128
|
+
# Drop the materialized view if it exists (idempotent).
|
129
|
+
#
|
130
|
+
# @api private
|
131
|
+
# @return [void]
|
132
|
+
#
|
133
|
+
def drop_view
|
134
|
+
conn.execute(<<~SQL)
|
135
|
+
DROP MATERIALIZED VIEW IF EXISTS #{qualified_rel}
|
136
|
+
SQL
|
137
|
+
end
|
138
|
+
|
139
|
+
##
|
140
|
+
# Refresh strategy from the definition (stringified).
|
141
|
+
#
|
142
|
+
# @api private
|
143
|
+
# @return [String] one of `"regular"`, `"concurrent"`, `"swap"` (or custom)
|
144
|
+
#
|
145
|
+
def strategy
|
146
|
+
@strategy ||= definition.refresh_strategy.to_s
|
147
|
+
end
|
148
|
+
|
149
|
+
##
|
150
|
+
# Unqualified relation (matview) name from the definition.
|
151
|
+
#
|
152
|
+
# @api private
|
153
|
+
# @return [String]
|
154
|
+
#
|
155
|
+
def rel
|
156
|
+
@rel ||= definition.name.to_s
|
157
|
+
end
|
158
|
+
|
159
|
+
##
|
160
|
+
# SQL `SELECT …` for the materialization.
|
161
|
+
#
|
162
|
+
# @api private
|
163
|
+
# @return [String]
|
164
|
+
#
|
165
|
+
def sql
|
166
|
+
@sql ||= definition.sql.to_s
|
167
|
+
end
|
168
|
+
|
169
|
+
##
|
170
|
+
# Unique index column list (normalized to strings, unique).
|
171
|
+
#
|
172
|
+
# @api private
|
173
|
+
# @return [Array<String>]
|
174
|
+
#
|
175
|
+
def cols
|
176
|
+
@cols ||= Array(definition.unique_index_columns).map(&:to_s).uniq
|
177
|
+
end
|
178
|
+
|
179
|
+
##
|
180
|
+
# ActiveRecord connection.
|
181
|
+
#
|
182
|
+
# @api private
|
183
|
+
# @return [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
184
|
+
#
|
185
|
+
def conn
|
186
|
+
@conn ||= ActiveRecord::Base.connection
|
187
|
+
end
|
188
|
+
|
189
|
+
##
|
190
|
+
# Resolved, existing schema for this operation.
|
191
|
+
#
|
192
|
+
# @api private
|
193
|
+
# @return [String]
|
194
|
+
#
|
195
|
+
def schema
|
196
|
+
@schema ||= first_existing_schema
|
197
|
+
end
|
198
|
+
|
199
|
+
# ────────────────────────────────────────────────────────────────
|
200
|
+
# Response helpers
|
201
|
+
# ────────────────────────────────────────────────────────────────
|
202
|
+
|
203
|
+
##
|
204
|
+
# Build a success response.
|
205
|
+
#
|
206
|
+
# @api private
|
207
|
+
# @param status [Symbol] e.g., `:ok`, `:created`, `:updated`, `:noop`
|
208
|
+
# @param payload [Hash] optional payload
|
209
|
+
# @param meta [Hash] optional metadata
|
210
|
+
# @return [MatViews::ServiceResponse]
|
211
|
+
#
|
212
|
+
def ok(status, payload: {}, meta: {})
|
213
|
+
MatViews::ServiceResponse.new(status: status, payload: payload, meta: meta)
|
214
|
+
end
|
215
|
+
|
216
|
+
##
|
217
|
+
# Build an error response with a message.
|
218
|
+
#
|
219
|
+
# @api private
|
220
|
+
# @param msg [String]
|
221
|
+
# @return [MatViews::ServiceResponse]
|
222
|
+
#
|
223
|
+
def err(msg)
|
224
|
+
MatViews::ServiceResponse.new(status: :error, error: msg)
|
225
|
+
end
|
226
|
+
|
227
|
+
##
|
228
|
+
# Build an error response from an exception, including backtrace.
|
229
|
+
#
|
230
|
+
# @api private
|
231
|
+
# @param exception [Exception]
|
232
|
+
# @param payload [Hash]
|
233
|
+
# @param meta [Hash]
|
234
|
+
# @return [MatViews::ServiceResponse]
|
235
|
+
#
|
236
|
+
def error_response(exception, payload: {}, meta: {})
|
237
|
+
MatViews::ServiceResponse.new(
|
238
|
+
status: :error,
|
239
|
+
error: "#{exception.class}: #{exception.message}",
|
240
|
+
payload: payload,
|
241
|
+
meta: { backtrace: Array(exception.backtrace), **meta }
|
242
|
+
)
|
243
|
+
end
|
244
|
+
|
245
|
+
# ────────────────────────────────────────────────────────────────
|
246
|
+
# Quoting / environment helpers
|
247
|
+
# ────────────────────────────────────────────────────────────────
|
248
|
+
|
249
|
+
##
|
250
|
+
# Quote a column name for SQL.
|
251
|
+
#
|
252
|
+
# @api private
|
253
|
+
# @param name [String, Symbol]
|
254
|
+
# @return [String] quoted column name
|
255
|
+
#
|
256
|
+
def quote_column_name(name)
|
257
|
+
conn.quote_column_name(name)
|
258
|
+
end
|
259
|
+
|
260
|
+
##
|
261
|
+
# Quote a table/relation name for SQL.
|
262
|
+
#
|
263
|
+
# @api private
|
264
|
+
# @param name [String, Symbol]
|
265
|
+
# @return [String] quoted relation name
|
266
|
+
#
|
267
|
+
def quote_table_name(name)
|
268
|
+
conn.quote_table_name(name)
|
269
|
+
end
|
270
|
+
|
271
|
+
##
|
272
|
+
# Whether the underlying PG connection is idle (no active tx/savepoint).
|
273
|
+
#
|
274
|
+
# Used to guard `CONCURRENTLY` operations which must run outside a txn.
|
275
|
+
#
|
276
|
+
# @api private
|
277
|
+
# @return [Boolean]
|
278
|
+
#
|
279
|
+
def pg_idle?
|
280
|
+
rc = conn.raw_connection
|
281
|
+
status = rc.respond_to?(:transaction_status) ? rc.transaction_status : nil
|
282
|
+
# Only use CONCURRENTLY outside any tx/savepoint.
|
283
|
+
status.nil? || status == PG::PQTRANS_IDLE
|
284
|
+
rescue StandardError
|
285
|
+
false
|
286
|
+
end
|
287
|
+
|
288
|
+
##
|
289
|
+
# Validate SQL starts with SELECT.
|
290
|
+
#
|
291
|
+
# @api private
|
292
|
+
# @return [Boolean]
|
293
|
+
#
|
294
|
+
def valid_sql?
|
295
|
+
definition.sql.to_s.strip.upcase.start_with?('SELECT')
|
296
|
+
end
|
297
|
+
|
298
|
+
##
|
299
|
+
# Validate that the view name is a sane PostgreSQL identifier.
|
300
|
+
#
|
301
|
+
# @api private
|
302
|
+
# @return [Boolean]
|
303
|
+
def valid_name?
|
304
|
+
/\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(definition.name.to_s)
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
@@ -0,0 +1,177 @@
|
|
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
|
+
module MatViews
|
9
|
+
module Services
|
10
|
+
##
|
11
|
+
# Refresh service that runs:
|
12
|
+
#
|
13
|
+
# `REFRESH MATERIALIZED VIEW CONCURRENTLY <schema>.<rel>`
|
14
|
+
#
|
15
|
+
# It keeps the view readable during refresh, but **requires at least one
|
16
|
+
# UNIQUE index** on the materialized view (a PostgreSQL constraint).
|
17
|
+
# Returns a {MatViews::ServiceResponse}.
|
18
|
+
#
|
19
|
+
# Row-count reporting is optional and controlled by `row_count_strategy`:
|
20
|
+
# - `:estimated` — fast/approx via `pg_class.reltuples`
|
21
|
+
# - `:exact` — accurate `COUNT(*)`
|
22
|
+
# - `nil` (or any unrecognized value) — skip counting
|
23
|
+
#
|
24
|
+
# @see MatViews::Services::RegularRefresh
|
25
|
+
# @see MatViews::Services::SwapRefresh
|
26
|
+
#
|
27
|
+
# @example Direct usage
|
28
|
+
# svc = MatViews::Services::ConcurrentRefresh.new(definition, row_count_strategy: :exact)
|
29
|
+
# response = svc.run
|
30
|
+
# response.success? # => true/false
|
31
|
+
#
|
32
|
+
# @example Via job selection (within RefreshViewJob)
|
33
|
+
# # When definition.refresh_strategy == "concurrent"
|
34
|
+
# MatViews::RefreshViewJob.perform_later(definition.id, :estimated)
|
35
|
+
#
|
36
|
+
class ConcurrentRefresh < BaseService
|
37
|
+
##
|
38
|
+
# Strategy for computing row count after refresh.
|
39
|
+
#
|
40
|
+
# @return [Symbol, nil] one of `:estimated`, `:exact`, or `nil`
|
41
|
+
attr_reader :row_count_strategy
|
42
|
+
|
43
|
+
##
|
44
|
+
# @param definition [MatViews::MatViewDefinition]
|
45
|
+
# @param row_count_strategy [Symbol, nil] `:estimated` (default), `:exact`, or `nil`
|
46
|
+
def initialize(definition, row_count_strategy: :estimated)
|
47
|
+
super(definition)
|
48
|
+
@row_count_strategy = row_count_strategy
|
49
|
+
end
|
50
|
+
|
51
|
+
##
|
52
|
+
# Execute the concurrent refresh.
|
53
|
+
#
|
54
|
+
# Validates name format, existence of the matview, and presence of a UNIQUE index.
|
55
|
+
# If validation fails, returns an error {MatViews::ServiceResponse}.
|
56
|
+
#
|
57
|
+
# @return [MatViews::ServiceResponse]
|
58
|
+
# - `status: :updated` with payload `{ view:, row_count? }` on success
|
59
|
+
# - `status: :error` with `error` on failure
|
60
|
+
#
|
61
|
+
# @raise [StandardError] bubbled after being wrapped into {#error_response}
|
62
|
+
#
|
63
|
+
def run
|
64
|
+
prep = prepare!
|
65
|
+
return prep if prep
|
66
|
+
|
67
|
+
sql = "REFRESH MATERIALIZED VIEW CONCURRENTLY #{qualified_rel}"
|
68
|
+
|
69
|
+
conn.execute(sql)
|
70
|
+
|
71
|
+
payload = { view: "#{schema}.#{rel}" }
|
72
|
+
payload[:row_count] = fetch_rows_count if row_count_strategy.present?
|
73
|
+
|
74
|
+
ok(:updated,
|
75
|
+
payload: payload,
|
76
|
+
meta: { sql: sql, row_count_strategy: row_count_strategy, concurrent: true })
|
77
|
+
rescue PG::ObjectInUse => e
|
78
|
+
# Common lock/contention error during concurrent refreshes.
|
79
|
+
error_response(e,
|
80
|
+
meta: { sql: sql, row_count_strategy: row_count_strategy, concurrent: true },
|
81
|
+
payload: { view: "#{schema}.#{rel}" })
|
82
|
+
rescue StandardError => e
|
83
|
+
error_response(e,
|
84
|
+
meta: { sql: sql, backtrace: Array(e.backtrace), row_count_strategy: row_count_strategy, concurrent: true },
|
85
|
+
payload: { view: "#{schema}.#{rel}" })
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
# ────────────────────────────────────────────────────────────────
|
91
|
+
# internal
|
92
|
+
# ────────────────────────────────────────────────────────────────
|
93
|
+
|
94
|
+
##
|
95
|
+
# Perform pre-flight checks.
|
96
|
+
#
|
97
|
+
# @api private
|
98
|
+
# @return [MatViews::ServiceResponse, nil] error response or `nil` if OK
|
99
|
+
#
|
100
|
+
def prepare!
|
101
|
+
return err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
|
102
|
+
return err("Materialized view #{schema}.#{rel} does not exist") unless view_exists?
|
103
|
+
return err("Materialized view #{schema}.#{rel} must have a unique index for concurrent refresh") unless unique_index_exists?
|
104
|
+
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
|
108
|
+
# ────────────────────────────────────────────────────────────────
|
109
|
+
# helpers: validation / schema / pg introspection
|
110
|
+
# (mirrors RegularRefresh for consistency)
|
111
|
+
# ────────────────────────────────────────────────────────────────
|
112
|
+
|
113
|
+
##
|
114
|
+
# Check for any UNIQUE index on the materialized view, required by CONCURRENTLY.
|
115
|
+
#
|
116
|
+
# @api private
|
117
|
+
# @return [Boolean]
|
118
|
+
#
|
119
|
+
def unique_index_exists?
|
120
|
+
conn.select_value(<<~SQL).to_i.positive?
|
121
|
+
SELECT COUNT(*)
|
122
|
+
FROM pg_index i
|
123
|
+
JOIN pg_class c ON c.oid = i.indrelid
|
124
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
125
|
+
WHERE n.nspname = #{conn.quote(schema)}
|
126
|
+
AND c.relname = #{conn.quote(rel)}
|
127
|
+
AND i.indisunique = TRUE
|
128
|
+
SQL
|
129
|
+
end
|
130
|
+
|
131
|
+
# ────────────────────────────────────────────────────────────────
|
132
|
+
# rows counting (same as RegularRefresh)
|
133
|
+
# ────────────────────────────────────────────────────────────────
|
134
|
+
|
135
|
+
##
|
136
|
+
# Compute row count based on the configured strategy.
|
137
|
+
#
|
138
|
+
# @api private
|
139
|
+
# @return [Integer, nil]
|
140
|
+
#
|
141
|
+
def fetch_rows_count
|
142
|
+
case row_count_strategy
|
143
|
+
when :estimated then estimated_rows_count
|
144
|
+
when :exact then exact_rows_count
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
##
|
149
|
+
# Fast, approximate row count via `pg_class.reltuples`.
|
150
|
+
#
|
151
|
+
# @api private
|
152
|
+
# @return [Integer]
|
153
|
+
#
|
154
|
+
def estimated_rows_count
|
155
|
+
conn.select_value(<<~SQL).to_i
|
156
|
+
SELECT COALESCE(c.reltuples::bigint, 0)
|
157
|
+
FROM pg_class c
|
158
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
159
|
+
WHERE c.relkind IN ('m','r','p')
|
160
|
+
AND n.nspname = #{conn.quote(schema)}
|
161
|
+
AND c.relname = #{conn.quote(rel)}
|
162
|
+
LIMIT 1
|
163
|
+
SQL
|
164
|
+
end
|
165
|
+
|
166
|
+
##
|
167
|
+
# Accurate row count using `COUNT(*)` on the materialized view.
|
168
|
+
#
|
169
|
+
# @api private
|
170
|
+
# @return [Integer]
|
171
|
+
#
|
172
|
+
def exact_rows_count
|
173
|
+
conn.select_value("SELECT COUNT(*) FROM #{qualified_rel}").to_i
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|