mat_views 0.1.2 → 0.2.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.
@@ -0,0 +1,20 @@
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
+ # Extend the Exception class to add a method for serializing error details.
9
+ class Exception
10
+ # Serialize the exception into a hash with message, class, and backtrace.
11
+ #
12
+ # @return [Hash] serialized error details
13
+ def mv_serialize_error
14
+ {
15
+ message: message,
16
+ class: self.class.name,
17
+ backtrace: Array(backtrace)
18
+ }
19
+ end
20
+ end
@@ -23,8 +23,8 @@ class CreateMatViewRuns < ActiveRecord::Migration[7.1]
23
23
  t.datetime :started_at, comment: 'Timestamp when the operation started'
24
24
  t.datetime :finished_at, comment: 'Timestamp when the operation finished'
25
25
  t.integer :duration_ms, comment: 'Duration of the operation in milliseconds'
26
- t.text :error, comment: 'Error message if the operation failed'
27
- t.jsonb :meta, null: false, default: {}, comment: 'Additional metadata about the run, such as job ID, row count or parameters'
26
+ t.jsonb :error, comment: 'Error details if the operation failed. :message, :class, :backtrace'
27
+ t.jsonb :meta, null: false, default: {}, comment: 'Additional metadata about the run, such as job ID, row count or parameters'
28
28
 
29
29
  t.timestamps
30
30
  end
@@ -58,19 +58,22 @@ module MatViews
58
58
  # @raise [ArgumentError] if the configured adapter is not recognized.
59
59
  #
60
60
  def self.enqueue(job_class, queue:, args: [])
61
- case MatViews.configuration.job_adapter
61
+ queue_str = queue.to_s
62
+ job_adapter = MatViews.configuration.job_adapter
63
+
64
+ case job_adapter
62
65
  when :active_job
63
- job_class.set(queue: queue.to_s).perform_later(*args)
66
+ job_class.set(queue: queue_str).perform_later(*args)
64
67
  when :sidekiq
65
68
  Sidekiq::Client.push(
66
69
  'class' => job_class.name,
67
- 'queue' => queue.to_s,
70
+ 'queue' => queue_str,
68
71
  'args' => args
69
72
  )
70
73
  when :resque
71
- Resque.enqueue_to(queue.to_s, job_class, *args)
74
+ Resque.enqueue_to(queue_str, job_class, *args)
72
75
  else
73
- raise ArgumentError, "Unknown job adapter: #{MatViews.configuration.job_adapter.inspect}"
76
+ raise ArgumentError, "Unknown job adapter: #{job_adapter.inspect}"
74
77
  end
75
78
  end
76
79
  end
@@ -10,16 +10,19 @@ module MatViews
10
10
  # Encapsulates the result of a service operation within MatViews.
11
11
  #
12
12
  # Provides a consistent contract for all services by standardizing:
13
- # - `status`: Symbol representing outcome (`:ok`, `:created`, `:updated`, `:noop`,
13
+ # - `status`: Symbol representing outcome (`:ok`, `:created`, `:updated`,
14
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
15
+ # - `request`: Request detailed that service was invoked with
16
+ # - `response`: Response detailed that service returned, nil with :error status
17
+ # - `error`: Exception or error message, with :error status
18
+ # - `message`: String description of the error
19
+ # - `class`: Exception class name
20
+ # - `backtrace`: Array of strings
18
21
  #
19
22
  # @example Successful response
20
23
  # MatViews::ServiceResponse.new(
21
24
  # status: :updated,
22
- # payload: { view: "public.users_mv" }
25
+ # response: { ... }
23
26
  # )
24
27
  #
25
28
  # @example Error response
@@ -29,32 +32,44 @@ module MatViews
29
32
  # )
30
33
  #
31
34
  class ServiceResponse
32
- attr_reader :status, :payload, :error, :meta
35
+ attr_reader :status, :request, :error, :response
36
+
37
+ # acceptable status values
38
+ ACCEPTABLE_STATES = %i[ok created updated skipped deleted error].freeze
39
+
40
+ # statuses indicating success
41
+ OK_STATES = %i[ok created updated skipped deleted].freeze
42
+
43
+ # statuses indicating error
44
+ ERROR_STATES = %i[error].freeze
33
45
 
34
46
  # @param status [Symbol] the outcome status
35
- # @param payload [Hash] optional data payload
47
+ # @param request [Hash] request details
48
+ # @param response [Hash] response details
36
49
  # @param error [Exception, String, nil] error details if applicable
37
- # @param meta [Hash] additional metadata
38
- def initialize(status:, payload: {}, error: nil, meta: {})
50
+ def initialize(status:, request: {}, response: {}, error: nil)
51
+ raise ArgumentError, 'status is required' unless ACCEPTABLE_STATES.include?(status&.to_sym)
52
+ raise ArgumentError, 'error must be an Exception object' if error && !error.is_a?(Exception)
53
+
39
54
  @status = status.to_sym
40
- @payload = payload
41
- @error = error
42
- @meta = meta
55
+ @request = request
56
+ @response = response
57
+ @error = error&.mv_serialize_error
43
58
  end
44
59
 
45
60
  # @return [Boolean] whether the response represents a success
46
61
  def success?
47
- !error? && %i[ok created updated noop skipped deleted].include?(status)
62
+ OK_STATES.include?(status)
48
63
  end
49
64
 
50
65
  # @return [Boolean] whether the response represents an error
51
66
  def error?
52
- !error.nil? || status == :error
67
+ ERROR_STATES.include?(status)
53
68
  end
54
69
 
55
70
  # @return [Hash] hash representation of the response
56
71
  def to_h
57
- { status:, payload:, error:, meta: }
72
+ { status:, request:, response:, error: }.compact
58
73
  end
59
74
  end
60
75
  end
@@ -29,18 +29,115 @@ module MatViews
29
29
  # end
30
30
  #
31
31
  class BaseService
32
+ # Constant indicating unknown row count
33
+ UNKNOWN_ROW_COUNT = -1
34
+
35
+ # Allowed row count strategies
36
+ ALLOWED_ROW_STRATEGIES = %i[none estimated exact].freeze
37
+
38
+ # Default row count strategy
39
+ DEFAULT_ROW_STRATEGY = :estimated
40
+
41
+ # Default strategy when nil or unrecognized value is given
42
+ DEFAULT_NIL_STRATEGY = :none
43
+
32
44
  ##
33
45
  # @return [MatViews::MatViewDefinition] The target materialized view definition.
34
46
  attr_reader :definition
35
47
 
48
+ ##
49
+ # Row count strategy (`:estimated`, `:exact`, `nil`).
50
+ #
51
+ # @return [Symbol, nil]
52
+ attr_reader :row_count_strategy
53
+
54
+ ##
55
+ # request hash to be returned in service response
56
+ # @return [Hash]
57
+ attr_accessor :request
58
+
59
+ ##
60
+ # response hash to be returned in service response
61
+ # @return [Hash]
62
+ attr_accessor :response
63
+
36
64
  ##
37
65
  # @param definition [MatViews::MatViewDefinition]
38
- def initialize(definition)
66
+ # @param row_count_strategy [Symbol, nil] one of `:estimated`, `:exact`, or `nil` (default: `:estimated`)
67
+ #
68
+ def initialize(definition, row_count_strategy: DEFAULT_ROW_STRATEGY)
39
69
  @definition = definition
70
+ @row_count_strategy = extract_row_strategy(row_count_strategy)
71
+ @request = {}
72
+ @response = {}
73
+ end
74
+
75
+ ##
76
+ # Execute the service operation.
77
+ #
78
+ # Calls {#assign_request}, {#prepare} and {#_run} in order.
79
+ #
80
+ # Concrete subclasses must implement these methods.
81
+ #
82
+ # @return [MatViews::ServiceResponse]
83
+ # @raise [NotImplementedError] if not implemented in subclass
84
+ def run
85
+ assign_request
86
+ prepare
87
+ _run
88
+ rescue StandardError => e
89
+ error_response(e)
40
90
  end
41
91
 
42
92
  private
43
93
 
94
+ ##
95
+ # Assign the request parameters.
96
+ # Called by {#run} before {#prepare}.
97
+ #
98
+ # Must be implemented in concrete subclasses.
99
+ #
100
+ # @api private
101
+ # @return [void]
102
+ # @raise [NotImplementedError] if not implemented in subclass
103
+ #
104
+ def assign_request
105
+ raise NotImplementedError, "Must implement #{self.class}##{__method__}"
106
+ end
107
+
108
+ ##
109
+ # Perform pre-flight checks.
110
+ # Called by {#run} after {#assign_request}.
111
+ #
112
+ # Must be implemented in concrete subclasses.
113
+ #
114
+ # @api private
115
+ # @return [nil] on success
116
+ # @raise [StandardError] on failure
117
+ # @raise [NotImplementedError] if not implemented in subclass
118
+ #
119
+ def prepare
120
+ raise NotImplementedError, "Must implement #{self.class}##{__method__}"
121
+ end
122
+
123
+ ##
124
+ # Execute the service operation.
125
+ # Called by {#run} after {#prepare}.
126
+ #
127
+ # Must be implemented in concrete subclasses.
128
+ #
129
+ # @api private
130
+ # @return [MatViews::ServiceResponse]
131
+ # @raise [NotImplementedError] if not implemented in subclass
132
+ #
133
+ def _run
134
+ raise NotImplementedError, "Must implement #{self.class}##{__method__}"
135
+ end
136
+
137
+ def extract_row_strategy(value)
138
+ ALLOWED_ROW_STRATEGIES.include?(value) ? value : DEFAULT_NIL_STRATEGY
139
+ end
140
+
44
141
  # ────────────────────────────────────────────────────────────────
45
142
  # Schema / resolution helpers
46
143
  # ────────────────────────────────────────────────────────────────
@@ -56,9 +153,9 @@ module MatViews
56
153
  #
57
154
  def first_existing_schema
58
155
  raw_path = conn.schema_search_path.presence || 'public'
59
- candidates = raw_path.split(',').filter_map { |t| resolve_schema_token(t.strip) }
156
+ candidates = raw_path.split(',').filter_map { |token| resolve_schema_token(token.strip) }
60
157
  candidates << 'public' unless candidates.include?('public')
61
- candidates.find { |s| schema_exists?(s) } || 'public'
158
+ candidates.find { |schema_str| schema_exists?(schema_str) } || 'public'
62
159
  end
63
160
 
64
161
  ##
@@ -204,42 +301,34 @@ module MatViews
204
301
  # Build a success response.
205
302
  #
206
303
  # @api private
207
- # @param status [Symbol] e.g., `:ok`, `:created`, `:updated`, `:noop`
208
- # @param payload [Hash] optional payload
209
- # @param meta [Hash] optional metadata
304
+ # @param status [Symbol] e.g., `:ok`, `:created`, `:updated`, `:skipped`, `:deleted`
210
305
  # @return [MatViews::ServiceResponse]
211
306
  #
212
- def ok(status, payload: {}, meta: {})
213
- MatViews::ServiceResponse.new(status: status, payload: payload, meta: meta)
307
+ def ok(status)
308
+ MatViews::ServiceResponse.new(status:, request:, response:)
214
309
  end
215
310
 
216
311
  ##
217
- # Build an error response with a message.
312
+ # Raise a StandardError with the given message.
218
313
  #
219
314
  # @api private
220
315
  # @param msg [String]
221
- # @return [MatViews::ServiceResponse]
316
+ # @return [void]
317
+ # @raise [StandardError] with `msg`
222
318
  #
223
- def err(msg)
224
- MatViews::ServiceResponse.new(status: :error, error: msg)
319
+ def raise_err(msg)
320
+ raise StandardError, msg
225
321
  end
226
322
 
227
323
  ##
228
324
  # Build an error response from an exception, including backtrace.
229
325
  #
230
326
  # @api private
231
- # @param exception [Exception]
232
- # @param payload [Hash]
233
- # @param meta [Hash]
327
+ # @param error [Exception]
234
328
  # @return [MatViews::ServiceResponse]
235
329
  #
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
- )
330
+ def error_response(error)
331
+ MatViews::ServiceResponse.new(status: :error, error:, request:, response:)
243
332
  end
244
333
 
245
334
  # ────────────────────────────────────────────────────────────────
@@ -278,9 +367,10 @@ module MatViews
278
367
  #
279
368
  def pg_idle?
280
369
  rc = conn.raw_connection
281
- status = rc.respond_to?(:transaction_status) ? rc.transaction_status : nil
370
+ return true unless rc.respond_to?(:transaction_status)
371
+
282
372
  # Only use CONCURRENTLY outside any tx/savepoint.
283
- status.nil? || status == PG::PQTRANS_IDLE
373
+ rc.transaction_status == PG::PQTRANS_IDLE
284
374
  rescue StandardError
285
375
  false
286
376
  end
@@ -303,6 +393,71 @@ module MatViews
303
393
  def valid_name?
304
394
  /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(definition.name.to_s)
305
395
  end
396
+
397
+ ##
398
+ # Check for any UNIQUE index on the materialized view, required by CONCURRENTLY.
399
+ #
400
+ # @api private
401
+ # @return [Boolean]
402
+ #
403
+ def unique_index_exists?
404
+ conn.select_value(<<~SQL).to_i.positive?
405
+ SELECT COUNT(*)
406
+ FROM pg_index i
407
+ JOIN pg_class c ON c.oid = i.indrelid
408
+ JOIN pg_namespace n ON n.oid = c.relnamespace
409
+ WHERE n.nspname = #{conn.quote(schema)}
410
+ AND c.relname = #{conn.quote(rel)}
411
+ AND i.indisunique = TRUE
412
+ SQL
413
+ end
414
+
415
+ # ────────────────────────────────────────────────────────────────
416
+ # rows counting
417
+ # ────────────────────────────────────────────────────────────────
418
+
419
+ ##
420
+ # Compute row count based on the configured strategy.
421
+ #
422
+ # @api private
423
+ # @return [Integer, nil]
424
+ #
425
+ def fetch_rows_count
426
+ case row_count_strategy
427
+ when :estimated then estimated_rows_count
428
+ when :exact then exact_rows_count
429
+ else
430
+ UNKNOWN_ROW_COUNT
431
+ end
432
+ end
433
+
434
+ ##
435
+ # Fast, approximate row count via `pg_class.reltuples`.
436
+ #
437
+ # @api private
438
+ # @return [Integer]
439
+ #
440
+ def estimated_rows_count
441
+ conn.select_value(<<~SQL).to_i
442
+ SELECT COALESCE(c.reltuples::bigint, 0)
443
+ FROM pg_class c
444
+ JOIN pg_namespace n ON n.oid = c.relnamespace
445
+ WHERE c.relkind IN ('m','r','p')
446
+ AND n.nspname = #{conn.quote(schema)}
447
+ AND c.relname = #{conn.quote(rel)}
448
+ LIMIT 1
449
+ SQL
450
+ end
451
+
452
+ ##
453
+ # Accurate row count using `COUNT(*)` on the materialized view.
454
+ #
455
+ # @api private
456
+ # @return [Integer]
457
+ #
458
+ def exact_rows_count
459
+ conn.select_value("SELECT COUNT(*) FROM #{qualified_rel}").to_i
460
+ end
306
461
  end
307
462
  end
308
463
  end
@@ -14,39 +14,26 @@ module MatViews
14
14
  #
15
15
  # It keeps the view readable during refresh, but **requires at least one
16
16
  # UNIQUE index** on the materialized view (a PostgreSQL constraint).
17
- # Returns a {MatViews::ServiceResponse}.
18
17
  #
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
18
+ # Options:
19
+ # - `row_count_strategy:` (Symbol, default: :none) → one of `:estimated`, `:exact`, or `:none or nil` to control row count reporting
20
+ #
21
+ # Returns a {MatViews::ServiceResponse}
23
22
  #
24
23
  # @see MatViews::Services::RegularRefresh
25
24
  # @see MatViews::Services::SwapRefresh
26
25
  #
27
26
  # @example Direct usage
28
- # svc = MatViews::Services::ConcurrentRefresh.new(definition, row_count_strategy: :exact)
27
+ # svc = MatViews::Services::ConcurrentRefresh.new(definition, **options)
29
28
  # response = svc.run
30
29
  # response.success? # => true/false
31
30
  #
32
- # @example Via job selection (within RefreshViewJob)
33
- # # When definition.refresh_strategy == "concurrent"
34
- # MatViews::RefreshViewJob.perform_later(definition.id, :estimated)
31
+ # @example via job, this is the typical usage and will create a run record in the DB
32
+ # When definition.refresh_strategy == "concurrent"
33
+ # MatViews::Jobs::Adapter.enqueue(MatViews::Services::RefreshViewJob, definition.id, **options)
35
34
  #
36
35
  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
36
+ private
50
37
 
51
38
  ##
52
39
  # Execute the concurrent refresh.
@@ -55,122 +42,49 @@ module MatViews
55
42
  # If validation fails, returns an error {MatViews::ServiceResponse}.
56
43
  #
57
44
  # @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
-
45
+ # - `status: :updated` on success, with `response` containing:
46
+ # - `view` - the qualified view name
47
+ # - `row_count_before` - if requested and available
48
+ # - `row_count_after` - if requested and available
49
+ # - `status: :error` with `error` on failure, with `error` containing:
50
+ # - serlialized exception class, message, and backtrace in a hash
51
+ def _run
67
52
  sql = "REFRESH MATERIALIZED VIEW CONCURRENTLY #{qualified_rel}"
53
+ self.response = { view: "#{schema}.#{rel}", sql: [sql] }
68
54
 
55
+ response[:row_count_before] = fetch_rows_count
69
56
  conn.execute(sql)
57
+ response[:row_count_after] = fetch_rows_count
70
58
 
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
59
+ ok(:updated)
106
60
  end
107
61
 
108
- # ────────────────────────────────────────────────────────────────
109
- # helpers: validation / schema / pg introspection
110
- # (mirrors RegularRefresh for consistency)
111
- # ────────────────────────────────────────────────────────────────
112
-
113
62
  ##
114
- # Check for any UNIQUE index on the materialized view, required by CONCURRENTLY.
63
+ # Assign the request parameters.
64
+ # Called by {#run} before {#prepare}.
65
+ # Sets `concurrent: true` in the request hash.
115
66
  #
116
67
  # @api private
117
- # @return [Boolean]
68
+ # @return [void]
118
69
  #
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
70
+ def assign_request
71
+ self.request = { row_count_strategy: row_count_strategy, concurrent: true }
129
72
  end
130
73
 
131
- # ────────────────────────────────────────────────────────────────
132
- # rows counting (same as RegularRefresh)
133
- # ────────────────────────────────────────────────────────────────
134
-
135
74
  ##
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`.
75
+ # Perform pre-flight checks.
76
+ # Called by {#run} after {#assign_request}.
150
77
  #
151
78
  # @api private
152
- # @return [Integer]
79
+ # @return [nil] on success
80
+ # @raise [StandardError] on failure
153
81
  #
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
82
+ def prepare
83
+ raise_err("Invalid view name format: #{definition.name.inspect}") unless valid_name?
84
+ raise_err("Materialized view #{schema}.#{rel} does not exist") unless view_exists?
85
+ raise_err("Materialized view #{schema}.#{rel} must have a unique index for concurrent refresh") unless unique_index_exists?
165
86
 
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
87
+ nil
174
88
  end
175
89
  end
176
90
  end