geneva_drive 0.5.0 → 0.7.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/MANUAL.md +449 -11
  4. data/README.md +5 -1
  5. data/lib/generators/geneva_drive/install/install_generator.rb +20 -0
  6. data/lib/generators/geneva_drive/install/templates/add_metadata_to_workflows.rb +17 -0
  7. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +29 -0
  8. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +94 -0
  9. data/lib/generators/geneva_drive/install/templates/add_started_at_index_to_step_executions.rb +48 -0
  10. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +11 -0
  11. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +8 -0
  12. data/lib/geneva_drive/combined_exception_policy.rb +1 -1
  13. data/lib/geneva_drive/exception_policy.rb +36 -10
  14. data/lib/geneva_drive/executor.rb +352 -26
  15. data/lib/geneva_drive/flow_control.rb +87 -7
  16. data/lib/geneva_drive/iterable_step.rb +199 -0
  17. data/lib/geneva_drive/job_options.rb +70 -0
  18. data/lib/geneva_drive/jobs/housekeeping_job.rb +120 -7
  19. data/lib/geneva_drive/resumable_step_definition.rb +97 -0
  20. data/lib/geneva_drive/signal.rb +243 -0
  21. data/lib/geneva_drive/signal_matcher.rb +60 -0
  22. data/lib/geneva_drive/step_definition.rb +96 -2
  23. data/lib/geneva_drive/step_execution.rb +141 -2
  24. data/lib/geneva_drive/test_helpers.rb +212 -6
  25. data/lib/geneva_drive/version.rb +1 -1
  26. data/lib/geneva_drive/workflow/metadata_accessor.rb +85 -0
  27. data/lib/geneva_drive/workflow.rb +559 -43
  28. data/lib/geneva_drive.rb +37 -0
  29. data/test/dsl/signal_step_definition_test.rb +132 -0
  30. data/test/dsl/step_definition_test.rb +70 -0
  31. data/test/jobs/housekeeping_job_test.rb +119 -0
  32. data/test/jobs/housekeeping_signals_test.rb +198 -0
  33. data/test/jobs/perform_step_job_test.rb +25 -2
  34. data/test/migration_helpers_test.rb +1 -1
  35. data/test/test_helper.rb +4 -0
  36. data/test/test_helper_test.rb +281 -0
  37. data/test/workflow/cursor_size_limit_test.rb +75 -0
  38. data/test/workflow/instrumentation_test.rb +28 -0
  39. data/test/workflow/resumable_step_integration_test.rb +341 -0
  40. data/test/workflow/resumable_step_test.rb +615 -0
  41. data/test/workflow/resumable_without_migration_test.rb +103 -0
  42. data/test/workflow/signal_consumption_test.rb +397 -0
  43. data/test/workflow/signal_delivery_test.rb +255 -0
  44. data/test/workflow/signal_durability_test.rb +164 -0
  45. data/test/workflow/signal_rendezvous_test.rb +581 -0
  46. data/test/workflow/signals_without_migration_test.rb +97 -0
  47. data/test/workflow/workflow_test.rb +72 -1
  48. metadata +25 -15
  49. data/test/dummy/config/initializers/geneva_drive.rb +0 -44
  50. data/test/dummy/db/migrate/20260219212321_create_geneva_drive_workflows.rb +0 -74
  51. data/test/dummy/db/migrate/20260219212322_create_geneva_drive_step_executions.rb +0 -108
  52. data/test/dummy/db/migrate/20260219212323_add_finished_at_to_geneva_drive_step_executions.rb +0 -25
  53. data/test/dummy/db/migrate/20260219212324_add_error_class_name_to_geneva_drive_step_executions.rb +0 -7
  54. data/test/dummy/db/migrate/20260316100007_add_metadata_to_geneva_drive_step_executions.rb +0 -17
  55. data/test/dummy/db/migrate/20260327000000_allow_null_hero_on_geneva_drive_workflows.rb +0 -66
  56. data/test/dummy/db/schema.rb +0 -73
  57. data/test/dummy/log/development.log +0 -202
  58. data/test/dummy/log/test.log +0 -79471
  59. data/test/dummy/log/test.log.0 +0 -4
  60. data/test/dummy/tmp/local_secret.txt +0 -1
@@ -26,6 +26,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
26
26
  # Provides: scheduled, in_progress, etc. scopes
27
27
  enum :state, {
28
28
  scheduled: "scheduled",
29
+ waiting: "waiting",
29
30
  in_progress: "in_progress",
30
31
  completed: "completed",
31
32
  failed: "failed",
@@ -36,6 +37,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
36
37
  # Outcome values for audit purposes
37
38
  OUTCOMES = %w[
38
39
  success
40
+ continued
39
41
  reattempted
40
42
  skipped
41
43
  canceled
@@ -50,6 +52,26 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
50
52
  foreign_key: :workflow_id,
51
53
  inverse_of: :step_executions
52
54
 
55
+ # For chained resumable step executions
56
+ belongs_to :continues_from,
57
+ class_name: "GenevaDrive::StepExecution",
58
+ foreign_key: :continues_from_id,
59
+ optional: true,
60
+ inverse_of: :successor
61
+
62
+ has_one :successor,
63
+ class_name: "GenevaDrive::StepExecution",
64
+ foreign_key: :continues_from_id,
65
+ inverse_of: :continues_from
66
+
67
+ # The signal this execution is processing (the "attachment pin"). Set once,
68
+ # at the gate or by dispatch, and copied to successor executions so the
69
+ # payload stays stable across a whole resumable chain.
70
+ belongs_to :signal,
71
+ class_name: "GenevaDrive::Signal",
72
+ foreign_key: :signal_id,
73
+ optional: true
74
+
53
75
  # Validations
54
76
  validates :step_name, presence: true
55
77
  validates :scheduled_for, presence: true
@@ -60,6 +82,90 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
60
82
  scheduled.where("scheduled_for <= ?", Time.current)
61
83
  }
62
84
 
85
+ class << self
86
+ # Lazily checks whether the resumable-step columns (cursor and
87
+ # continues_from_id) have been migrated. Never hits the database at
88
+ # class definition time — only on the first runtime call.
89
+ #
90
+ # Deployments usually ship the gem update before running migrations,
91
+ # so all cursor/chaining behavior must degrade safely when the columns
92
+ # are absent: reads return nil, writes are no-ops, and executing an
93
+ # actual resumable_step raises a clear configuration error instead.
94
+ #
95
+ # @return [Boolean]
96
+ def resumable_columns?
97
+ if defined?(@_resumable_columns)
98
+ return @_resumable_columns
99
+ end
100
+
101
+ @_resumable_columns = table_exists? &&
102
+ column_names.include?("cursor") &&
103
+ column_names.include?("continues_from_id")
104
+ end
105
+
106
+ # Clears the cached detection result. Call this in tests or after
107
+ # running migrations in-process so the next access re-checks.
108
+ #
109
+ # @return [void]
110
+ def reset_resumable_columns_cache!
111
+ remove_instance_variable(:@_resumable_columns) if defined?(@_resumable_columns)
112
+ end
113
+
114
+ # Lazily checks whether the signal columns (signal_id and
115
+ # waiting_since) have been migrated, mirroring resumable_columns?.
116
+ # Never hits the database at class definition time.
117
+ #
118
+ # Everything unrelated to signals keeps working without them; a step
119
+ # declaring wait_for: fails loudly with a "run the install generator"
120
+ # message instead of silently running without its event.
121
+ #
122
+ # @return [Boolean]
123
+ def signal_columns?
124
+ if defined?(@_signal_columns)
125
+ return @_signal_columns
126
+ end
127
+
128
+ @_signal_columns = table_exists? &&
129
+ column_names.include?("signal_id") &&
130
+ column_names.include?("waiting_since")
131
+ end
132
+
133
+ # Clears the cached signal column detection result. Call this in tests or
134
+ # after running migrations in-process so the next access re-checks.
135
+ #
136
+ # @return [void]
137
+ def reset_signal_columns_cache!
138
+ remove_instance_variable(:@_signal_columns) if defined?(@_signal_columns)
139
+ end
140
+
141
+ # Serializes a cursor value using ActiveJob serializers (handles Date,
142
+ # Time, and other types) and enforces GenevaDrive.max_cursor_size on
143
+ # the serialized JSON. The single serialization path for cursor writes.
144
+ #
145
+ # @param value [Object, nil] the cursor value
146
+ # @return [Object, nil] the serialized cursor
147
+ # @raise [CursorTooLargeError] if the serialized JSON exceeds the limit
148
+ def serialize_cursor(value)
149
+ return nil if value.nil?
150
+
151
+ serialized = ActiveJob::Arguments.serialize([value]).first
152
+
153
+ limit = GenevaDrive.max_cursor_size
154
+ if limit
155
+ bytesize = JSON.generate(serialized).bytesize
156
+ if bytesize > limit
157
+ raise GenevaDrive::CursorTooLargeError,
158
+ "Serialized cursor is #{bytesize} bytes, exceeding GenevaDrive.max_cursor_size " \
159
+ "(#{limit} bytes). The cursor is a position marker (an id, page number, or token), " \
160
+ "not a place to store the data being processed. Set GenevaDrive.max_cursor_size to " \
161
+ "nil to disable this check."
162
+ end
163
+ end
164
+
165
+ serialized
166
+ end
167
+ end
168
+
63
169
  # Transitions the step execution to 'in_progress' state.
64
170
  # Uses pessimistic locking to prevent double execution.
65
171
  #
@@ -136,6 +242,37 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
136
242
  end
137
243
  end
138
244
 
245
+ # Returns the deserialized cursor value for resumable steps.
246
+ # Uses ActiveJob serializers to handle Date, Time, and other types.
247
+ # Returns nil when the cursor column has not been migrated yet.
248
+ #
249
+ # @return [Object, nil] the cursor value
250
+ def cursor_value
251
+ return nil unless self.class.resumable_columns?
252
+ return nil if cursor.blank?
253
+ ActiveJob::Arguments.deserialize([cursor]).first
254
+ end
255
+
256
+ # Sets the cursor value for resumable steps.
257
+ # Uses ActiveJob serializers to handle Date, Time, and other types.
258
+ # Silent no-op when the cursor column has not been migrated yet.
259
+ #
260
+ # @param value [Object] the cursor value to store
261
+ # @raise [CursorTooLargeError] if the serialized JSON exceeds GenevaDrive.max_cursor_size
262
+ # @return [void]
263
+ def cursor_value=(value)
264
+ return unless self.class.resumable_columns?
265
+ self.cursor = self.class.serialize_cursor(value)
266
+ end
267
+
268
+ # Returns true if this execution is resuming from a prior execution.
269
+ # Always false when the continues_from_id column has not been migrated yet.
270
+ #
271
+ # @return [Boolean]
272
+ def resuming?
273
+ self.class.resumable_columns? && continues_from_id.present?
274
+ end
275
+
139
276
  # Returns the step definition for this execution.
140
277
  #
141
278
  # @return [StepDefinition, nil] the step definition
@@ -145,9 +282,11 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
145
282
 
146
283
  # Executes this step using the Executor.
147
284
  #
285
+ # @param interruptible [Boolean] whether resumable steps respect interruption conditions
286
+ # @param max_iterations [Integer, nil] per-execution override of the step's max_iterations
148
287
  # @return [void]
149
- def execute!
150
- GenevaDrive::Executor.execute!(self)
288
+ def execute!(interruptible: true, max_iterations: nil)
289
+ GenevaDrive::Executor.execute!(self, interruptible: interruptible, max_iterations: max_iterations)
151
290
  end
152
291
 
153
292
  # Same as ActiveRecord::Base#logger but supplemented with tags for step and workflow
@@ -20,11 +20,11 @@ module GenevaDrive::TestHelpers
20
20
  # Executes all steps of a workflow synchronously for testing.
21
21
  #
22
22
  # This method runs through all scheduled steps, executing each one
23
- # immediately regardless of wait times. Useful for testing complete
24
- # workflow behavior.
23
+ # immediately regardless of wait times. Resumable steps are run to
24
+ # completion (interruption limits are disabled).
25
25
  #
26
26
  # @param workflow [GenevaDrive::Workflow] the workflow to run
27
- # @param max_iterations [Integer] maximum steps to execute (safety limit)
27
+ # @param max_iterations [Integer] maximum step executions (safety limit)
28
28
  # @return [GenevaDrive::Workflow] the workflow after execution
29
29
  # @raise [RuntimeError] if max_iterations is exceeded
30
30
  #
@@ -46,7 +46,10 @@ module GenevaDrive::TestHelpers
46
46
  step_execution = workflow.current_execution
47
47
  break unless step_execution
48
48
 
49
- step_execution.execute!
49
+ raise_if_parked!(step_execution, "speedrun_workflow")
50
+
51
+ # interruptible: false so resumable steps run to completion
52
+ step_execution.execute!(interruptible: false)
50
53
  iterations += 1
51
54
 
52
55
  if iterations >= max_iterations
@@ -78,6 +81,8 @@ module GenevaDrive::TestHelpers
78
81
  step_execution = workflow.current_execution
79
82
  return nil unless step_execution
80
83
 
84
+ raise_if_parked!(step_execution, "perform_next_step")
85
+
81
86
  step_execution.execute!
82
87
  workflow.reload
83
88
  step_execution
@@ -116,8 +121,9 @@ module GenevaDrive::TestHelpers
116
121
  "Available steps: #{available}"
117
122
  end
118
123
 
119
- # Cancel any existing scheduled step executions to satisfy uniqueness constraint
120
- workflow.step_executions.where(state: "scheduled").update_all(
124
+ # Cancel any existing scheduled or parked step executions to satisfy the
125
+ # uniqueness constraint
126
+ workflow.step_executions.where(state: %w[scheduled waiting]).update_all(
121
127
  state: "canceled",
122
128
  outcome: "canceled",
123
129
  canceled_at: Time.current
@@ -170,4 +176,204 @@ module GenevaDrive::TestHelpers
170
176
  assert_equal expected_state.to_s, workflow.state,
171
177
  "Expected workflow to be #{expected_state}, but was #{workflow.state}"
172
178
  end
179
+
180
+ # === Resumable Step Test Helpers ===
181
+
182
+ # Runs the current step to completion without advancing to the next step.
183
+ #
184
+ # For regular steps, this is equivalent to `perform_next_step`.
185
+ # For resumable steps, this disables interruption checks and runs until
186
+ # the step completes (or fails/cancels/skips).
187
+ #
188
+ # @param workflow [GenevaDrive::Workflow] the workflow containing the step
189
+ # @param max_executions [Integer] safety limit on job executions (default: 100)
190
+ # @return [GenevaDrive::StepExecution] the step execution
191
+ #
192
+ # @example Run current step to completion
193
+ # workflow = BulkEmailWorkflow.create!(hero: campaign)
194
+ # speedrun_current_step(workflow)
195
+ # assert_step_executed(workflow, :send_emails)
196
+ #
197
+ def speedrun_current_step(workflow, max_executions: 100)
198
+ workflow.reload
199
+ step_execution = workflow.current_execution
200
+ return nil unless step_execution
201
+
202
+ raise_if_parked!(step_execution, "speedrun_current_step")
203
+
204
+ executions = 0
205
+ original_step_name = step_execution.step_name
206
+
207
+ loop do
208
+ step_execution.reload
209
+ raise_if_parked!(step_execution, "speedrun_current_step")
210
+
211
+ # Follow the execution chain: a completed execution that spawned a
212
+ # successor means the step is still going (suspend! and skip_to! create
213
+ # successors even with interruption checks disabled).
214
+ if GenevaDrive::StepExecution.resumable_columns? &&
215
+ step_execution.completed? && (successor = step_execution.successor)
216
+ step_execution = successor
217
+ next
218
+ end
219
+
220
+ break if step_execution.completed? || step_execution.failed? || step_execution.canceled? || step_execution.skipped?
221
+
222
+ # interruptible: false so the step runs to completion
223
+ step_execution.execute!(interruptible: false)
224
+ executions += 1
225
+
226
+ if executions >= max_executions
227
+ raise "speedrun_current_step exceeded max_executions (#{max_executions}). " \
228
+ "Step '#{original_step_name}' may be in an infinite loop."
229
+ end
230
+ end
231
+
232
+ workflow.reload
233
+ step_execution.reload
234
+ step_execution
235
+ end
236
+
237
+ # Runs the current (resumable) step for a limited number of iterations,
238
+ # then lets it interrupt and create a successor execution.
239
+ #
240
+ # Useful for testing partial progress and cursor state of resumable steps.
241
+ #
242
+ # @param workflow [GenevaDrive::Workflow] the workflow containing the step
243
+ # @param count [Integer] the number of iterations to run before interrupting
244
+ # @return [GenevaDrive::StepExecution] the executed (now completed) step execution
245
+ #
246
+ # @example Run three iterations, then inspect the cursor
247
+ # run_iterations(workflow, count: 3)
248
+ # assert_cursor(workflow, 3)
249
+ #
250
+ def run_iterations(workflow, count:)
251
+ workflow.reload
252
+ step_execution = workflow.current_execution
253
+ raise "No current step execution to run" unless step_execution
254
+
255
+ step_execution.execute!(max_iterations: count)
256
+
257
+ workflow.reload
258
+ step_execution.reload
259
+ step_execution
260
+ end
261
+
262
+ # Asserts that a resumable step's cursor matches the expected value.
263
+ #
264
+ # @param workflow [GenevaDrive::Workflow] the workflow to check
265
+ # @param expected_cursor [Object] the expected cursor value
266
+ # @return [void]
267
+ #
268
+ # @example Check cursor position
269
+ # assert_cursor(workflow, 42)
270
+ # assert_cursor(workflow, "page_token_abc")
271
+ #
272
+ def assert_cursor(workflow, expected_cursor)
273
+ workflow.reload
274
+ execution = workflow.current_execution
275
+ actual = execution&.cursor_value
276
+
277
+ assert_equal expected_cursor, actual,
278
+ "Expected cursor #{expected_cursor.inspect}, got #{actual.inspect}"
279
+ end
280
+
281
+ # Asserts that a step completed and has a scheduled successor execution.
282
+ # Used to verify that a resumable step was interrupted and will continue.
283
+ #
284
+ # @param workflow [GenevaDrive::Workflow] the workflow to check
285
+ # @param step_name [String, Symbol] the step name to check
286
+ # @return [void]
287
+ #
288
+ # @example Check step has a scheduled successor
289
+ # assert_step_has_successor(workflow, :bulk_process)
290
+ #
291
+ def assert_step_has_successor(workflow, step_name)
292
+ workflow.reload
293
+ completed = workflow.step_executions.find_by(step_name: step_name.to_s, state: "completed")
294
+ assert completed, "Expected completed execution for step #{step_name}"
295
+
296
+ successor = completed.successor
297
+ assert successor, "Expected successor execution for step #{step_name}, but none found"
298
+ assert successor.scheduled?, "Expected successor to be scheduled, but was #{successor.state}"
299
+ end
300
+
301
+ # === Signal Test Helpers ===
302
+
303
+ # Asserts that the workflow is parked waiting for a signal.
304
+ #
305
+ # @param workflow [GenevaDrive::Workflow] the workflow to check
306
+ # @param signal_name [String, Symbol, nil] the signal name the step waits for
307
+ # @return [void]
308
+ #
309
+ # @example Check that the workflow is waiting
310
+ # assert_waiting_for_signal(workflow, :payment_confirmed)
311
+ #
312
+ def assert_waiting_for_signal(workflow, signal_name = nil)
313
+ workflow.reload
314
+ execution = workflow.step_executions.where(state: "waiting").first
315
+
316
+ assert execution,
317
+ "Expected #{workflow.class.name} to be waiting for a signal, but no parked step execution was found " \
318
+ "(current execution: #{workflow.current_execution&.state.inspect})"
319
+
320
+ return unless signal_name
321
+
322
+ matcher = execution.step_definition&.signal_matcher
323
+ assert_equal signal_name.to_s, matcher_name(matcher),
324
+ "Expected step '#{execution.step_name}' to be waiting for #{signal_name.inspect}, " \
325
+ "but it waits for #{matcher}"
326
+ end
327
+
328
+ # Asserts that a signal with the given name exists in the given state.
329
+ #
330
+ # @param workflow [GenevaDrive::Workflow] the workflow to check
331
+ # @param signal_name [String, Symbol] the signal name
332
+ # @param state [String, Symbol] the expected signal state
333
+ # @return [void]
334
+ #
335
+ # @example Check that the signal was consumed
336
+ # assert_signal_state(workflow, :payment_confirmed, :consumed)
337
+ #
338
+ def assert_signal_state(workflow, signal_name, state)
339
+ signal = workflow.signals.where(name: signal_name.to_s).order(:created_at, :id).last
340
+
341
+ assert signal, "Expected a #{signal_name.inspect} signal on #{workflow.class.name}, but none was found"
342
+ assert_equal state.to_s, signal.state,
343
+ "Expected signal #{signal_name.inspect} to be #{state}, but was #{signal.state}"
344
+ end
345
+
346
+ private
347
+
348
+ # The signal name a matcher accepts, when it has one to give. Custom
349
+ # matchers own their whole predicate and need not expose a name.
350
+ #
351
+ # @param matcher [Object, nil] anything responding to #matches?(signal)
352
+ # @return [String, nil]
353
+ def matcher_name(matcher)
354
+ matcher.name if matcher.respond_to?(:name)
355
+ end
356
+
357
+ # Raises a descriptive error when a step-driving helper runs into a parked
358
+ # execution. Without this a parked workflow would look like an infinite
359
+ # loop (or a silently passing assertion) instead of "you forgot to signal".
360
+ #
361
+ # @param step_execution [GenevaDrive::StepExecution] the execution about to be driven
362
+ # @param helper_name [String] the helper that was called
363
+ # @return [void]
364
+ def raise_if_parked!(step_execution, helper_name)
365
+ return unless step_execution.waiting?
366
+
367
+ matcher = begin
368
+ step_execution.step_definition&.signal_matcher
369
+ rescue
370
+ nil
371
+ end
372
+ waiting_for = matcher ? " waiting for #{matcher}" : ""
373
+ example_name = matcher_name(matcher)&.to_sym
374
+
375
+ raise "#{helper_name} cannot drive step '#{step_execution.step_name}': its execution is parked" \
376
+ "#{waiting_for}. Deliver the signal first, e.g. " \
377
+ "workflow.signal!(#{example_name.inspect}), then call #{helper_name} again."
378
+ end
173
379
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GenevaDrive
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Provides safe read/write access to the freeform JSON metadata column
4
+ # on workflows. All "does the column exist?" logic lives here so
5
+ # the rest of the codebase can call read_metadata / write_metadata without
6
+ # guarding. When the metadata column has not been migrated yet, writes
7
+ # are silent no-ops and reads return nil.
8
+ #
9
+ # Once the migration becomes mandatory, delete this concern and replace with
10
+ # a plain `attribute :metadata, :json, default: -> { {} }` on Workflow.
11
+ #
12
+ # @api private
13
+ module GenevaDrive::Workflow::MetadataAccessor
14
+ extend ActiveSupport::Concern
15
+
16
+ class_methods do
17
+ # Lazily checks whether the metadata column exists. Never hits the
18
+ # database at class definition time — only on the first runtime call.
19
+ #
20
+ # Serialization is handled manually in read/write_metadata rather
21
+ # than via `attribute :metadata, :json` to avoid timing issues —
22
+ # registering the JSON type after instances are already loaded
23
+ # causes those instances to persist Hashes via text-type `#to_s`
24
+ # instead of `#to_json`.
25
+ #
26
+ # @return [Boolean]
27
+ def metadata_column?
28
+ if defined?(@_metadata_column)
29
+ return @_metadata_column
30
+ end
31
+
32
+ @_metadata_column = table_exists? && column_names.include?("metadata")
33
+ end
34
+
35
+ # Clears the cached detection result. Call this in tests or after
36
+ # running migrations in-process so the next access re-checks.
37
+ #
38
+ # @return [void]
39
+ def reset_metadata_column_cache!
40
+ remove_instance_variable(:@_metadata_column) if defined?(@_metadata_column)
41
+ end
42
+ end
43
+
44
+ # Reads a single key from the metadata hash.
45
+ #
46
+ # @param key [String, Symbol] the metadata key
47
+ # @return [Object, nil] the value, or nil if the column is absent
48
+ def read_metadata(key)
49
+ return nil unless self.class.metadata_column?
50
+ parsed_metadata[key.to_s]
51
+ end
52
+
53
+ # Merges a key/value pair into the metadata hash (in-memory only, does
54
+ # not persist — call save!/update! separately or include in a broader
55
+ # update).
56
+ #
57
+ # @param key [String, Symbol] the metadata key
58
+ # @param value [Object] the value to store
59
+ # @return [void]
60
+ def write_metadata(key, value)
61
+ return unless self.class.metadata_column?
62
+ merged = parsed_metadata.merge(key.to_s => value)
63
+ write_attribute(:metadata, merged.to_json)
64
+ end
65
+
66
+ private
67
+
68
+ # Returns metadata as a Hash regardless of whether the JSON attribute
69
+ # type has been applied to this instance. On SQLite/MySQL the raw value
70
+ # may still be a JSON string if the record was loaded before the
71
+ # attribute type was lazily registered.
72
+ #
73
+ # @return [Hash]
74
+ def parsed_metadata
75
+ raw = metadata
76
+ case raw
77
+ when Hash then raw
78
+ when String then JSON.parse(raw)
79
+ when NilClass then {}
80
+ else {}
81
+ end
82
+ rescue JSON::ParserError
83
+ {}
84
+ end
85
+ end