Class: SolidObjects::Doctor

Inherits:
Object
  • Object
show all
Defined in:
lib/solid_objects/doctor.rb,
sig/generated/lib/solid_objects/doctor.rbs

Defined Under Namespace

Classes: Check, ProbeActor, Report

Constant Summary collapse

EXPECTED_COLUMNS =

Returns:

  • (Object)
{
  processes: %w[id kind hostname pid last_heartbeat_at shutdown_state],
  instances: %w[
    id actor_type actor_id state state_version next_message_sequence
    activation_owner_id activation_token activation_expires_at
    activation_generation
  ],
  messages: %w[
    id instance_id delivery_mode arguments sequence attempt_count request_id
    result error rejection completed_at rejected_at
  ],
  ready_messages: %w[id message_id instance_id sequence available_at],
  claimed_messages: %w[
    id message_id instance_id process_id activation_token
    activation_generation claimed_at
  ],
  reminders: %w[id instance_id operation next_run_at status],
  effects: %w[id message_id instance_id effect_id status available_at],
  broadcasts: %w[id message_id instance_id broadcast_id status available_at],
  dead_letters: %w[id message_id instance_id actor_type actor_id attempts]
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(connection: SolidObjects::Record.connection, configuration: SolidObjects.configuration) ⇒ Doctor

Returns a new instance of Doctor.

RBS:

  • (?connection: untyped, ?configuration: Configuration) -> void

Parameters:

  • connection: (Object) (defaults to: SolidObjects::Record.connection)
  • configuration: (Configuration) (defaults to: SolidObjects.configuration)


98
99
100
101
102
103
104
# File 'lib/solid_objects/doctor.rb', line 98

def initialize(
  connection: SolidObjects::Record.connection,
  configuration: SolidObjects.configuration
)
  @connection = connection
  @configuration = configuration
end

Instance Attribute Details

#configurationObject (readonly)

Returns the value of attribute configuration.

Returns:

  • (Object)


125
126
127
# File 'lib/solid_objects/doctor.rb', line 125

def configuration
  @configuration
end

#connectionObject (readonly)

Returns the value of attribute connection.

Returns:

  • (Object)


125
126
127
# File 'lib/solid_objects/doctor.rb', line 125

def connection
  @connection
end

Instance Method Details

#callReport

RBS:

  • () -> Report

Returns:



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/solid_objects/doctor.rb', line 107

def call
  configuration_check = check_configuration
  schema_check = check_schema
  checks = [
    configuration_check,
    schema_check,
    check_authorization,
    check_database_server,
    schema_check.failed? ? skipped_runtime : check_runtime,
    ready_for_round_trip?(configuration_check, schema_check) ?
      check_sync_round_trip :
      skipped_round_trip
  ]
  Report.new(checks:)
end

#check_authorizationCheck

RBS:

  • () -> Check

Returns:



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/solid_objects/doctor.rb', line 157

def check_authorization
  outcomes = policy_probes.to_h do |name, arguments|
    outcome = configuration.public_send(name).call(**arguments) ? :allow : :deny
    [ name, outcome ]
  rescue
    [ name, :unknown ]
  end
  allowed = outcomes.select { |_, outcome| outcome == :allow }.keys
  unknown = outcomes.select { |_, outcome| outcome == :unknown }.keys

  if allowed.empty? && unknown.empty?
    return warn_check(
      :authorization,
      "all five policies denied a neutral context; review the generated initializer before use"
    )
  end
  risky = allowed & %i[
    authorize_destroy
    authorize_subscription
    authorize_administration
  ]
  unless risky.empty?
    return warn_check(
      :authorization,
      "sensitive policies allowed a neutral context: #{risky.join(", ")}"
    )
  end
  unless unknown.empty?
    return warn_check(
      :authorization,
      "#{allowed.length} of 5 policies allowed a neutral context; " \
        "#{unknown.join(", ")} could not evaluate without application context"
    )
  end

  pass(:authorization, "#{allowed.length} of 5 policies allowed a neutral context")
end

#check_configurationCheck

RBS:

  • () -> Check

Returns:



128
129
130
131
132
133
# File 'lib/solid_objects/doctor.rb', line 128

def check_configuration
  configuration.validate!
  pass(:configuration, "configuration is valid")
rescue => error
  fail_check(:configuration, "#{error.class}: #{error.message}")
end

#check_database_serverCheck

RBS:

  • () -> Check

Returns:



196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/solid_objects/doctor.rb', line 196

def check_database_server
  adapter = SolidObjects.database_adapter
  observed = adapter.server_version
  reasons = adapter.unsupported_server_reasons(observed)
  return warn_check(:database_server, reasons.join("; ")) unless reasons.empty?

  pass(
    :database_server,
    "#{adapter.class.name.demodulize} #{observed} meets the tested minimum"
  )
rescue => error
  warn_check(:database_server, "#{error.class}: #{error.message}")
end

#check_runtimeCheck

RBS:

  • () -> Check

Returns:



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/solid_objects/doctor.rb', line 211

def check_runtime
  cutoff = SolidObjects.database_adapter.database_now -
    configuration.process_alive_threshold
  counts = Process
    .where(shutdown_state: "running", last_heartbeat_at: cutoff..)
    .group(:kind)
    .count
  if counts.empty?
    return info(
      :runtime,
      "no live runtime roles; workerless synchronous calls are available, asynchronous features are not"
    )
  end

  summary = counts.sort.map { |kind, count| "#{kind}=#{count}" }.join(", ")
  pass(:runtime, "live runtime roles: #{summary}")
rescue => error
  fail_check(:runtime, "#{error.class}: #{error.message}")
end

#check_schemaCheck

RBS:

  • () -> Check

Returns:



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/solid_objects/doctor.rb', line 136

def check_schema
  missing_tables = expected_table_names - connection.data_sources
  unless missing_tables.empty?
    return fail_check(:schema, "missing tables: #{missing_tables.join(", ")}")
  end

  missing_columns = EXPECTED_COLUMNS.each_with_object([]) do |(name, expected), missing|
    table_name = SolidObjects.table_name(name)
    actual = connection.columns(table_name).map(&:name)
    (expected - actual).each { |column| missing << "#{table_name}.#{column}" }
  end
  unless missing_columns.empty?
    return fail_check(:schema, "missing columns: #{missing_columns.join(", ")}")
  end

  pass(:schema, "schema matches the #{SolidObjects::VERSION} runtime")
rescue => error
  fail_check(:schema, "#{error.class}: #{error.message}")
end

#check_sync_round_tripCheck

RBS:

  • () -> Check

Returns:



232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/solid_objects/doctor.rb', line 232

def check_sync_round_trip
  actor_id = SecureRandom.uuid
  probe_registry = ProcessRegistry.new
  check = run_sync_probe(actor_id, probe_registry)
  leftovers = remove_probe_records(actor_id:, probe_registry:)
  return check if leftovers.empty? || check.failed?

  warn_check(
    :sync_round_trip,
    "#{check.message}; could not remove the #{leftovers.join(" and ")}"
  )
end

#delete_probe_actor(actor_id) ⇒ Boolean

RBS:

  • (String) -> bool

Parameters:

  • (String)

Returns:

  • (Boolean)


274
275
276
277
278
279
# File 'lib/solid_objects/doctor.rb', line 274

def delete_probe_actor(actor_id)
  Instance.where(actor_type: ProbeActor.actor_type, actor_id:).delete_all
  true
rescue
  false
end

#delete_probe_caller_process(probe_registry) ⇒ Boolean

RBS:

  • (ProcessRegistry) -> bool

Parameters:

Returns:

  • (Boolean)


282
283
284
285
286
287
288
289
290
291
# File 'lib/solid_objects/doctor.rb', line 282

def delete_probe_caller_process(probe_registry)
  process_record = probe_registry.process_record
  return true unless process_record

  probe_registry.stop
  process_record.delete
  true
rescue
  false
end

#expected_table_namesArray[String]

RBS:

  • () -> Array[String]

Returns:

  • (Array[String])


336
337
338
# File 'lib/solid_objects/doctor.rb', line 336

def expected_table_names
  EXPECTED_COLUMNS.keys.map { |name| SolidObjects.table_name(name) }
end

#fail_check(name, message) ⇒ Check

RBS:

  • (Symbol, String) -> Check

Parameters:

  • (Symbol)
  • (String)

Returns:



356
357
358
# File 'lib/solid_objects/doctor.rb', line 356

def fail_check(name, message)
  Check.new(name:, status: :fail, message:)
end

#info(name, message) ⇒ Check

RBS:

  • (Symbol, String) -> Check

Parameters:

  • (Symbol)
  • (String)

Returns:



346
347
348
# File 'lib/solid_objects/doctor.rb', line 346

def info(name, message)
  Check.new(name:, status: :info, message:)
end

#pass(name, message) ⇒ Check

RBS:

  • (Symbol, String) -> Check

Parameters:

  • (Symbol)
  • (String)

Returns:



341
342
343
# File 'lib/solid_objects/doctor.rb', line 341

def pass(name, message)
  Check.new(name:, status: :pass, message:)
end

#policy_probesHash[Symbol, Hash[Symbol, untyped]]

RBS:

  • () -> Hash[Symbol, Hash[Symbol, untyped]]

Returns:

  • (Hash[Symbol, Hash[Symbol, untyped]])


309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'lib/solid_objects/doctor.rb', line 309

def policy_probes
  actor_arguments = {
    actor_type: ProbeActor.actor_type,
    actor_id: "doctor",
    authorization_context: nil
  }
  {
    authorize_message: actor_arguments.merge(
      operation: "ping",
      arguments: { "value" => "doctor" }
    ),
    authorize_query: actor_arguments.merge(
      operation: "value",
      arguments: {}
    ),
    authorize_destroy: actor_arguments,
    authorize_subscription: actor_arguments,
    authorize_administration: {
      action: "doctor",
      resource: "runtime",
      resource_id: nil,
      authorization_context: nil
    }
  }
end

#ready_for_round_trip?(configuration_check, schema_check) ⇒ Boolean

RBS:

  • (Check, Check) -> bool

Parameters:

Returns:

  • (Boolean)


294
295
296
# File 'lib/solid_objects/doctor.rb', line 294

def ready_for_round_trip?(configuration_check, schema_check)
  !configuration_check.failed? && !schema_check.failed?
end

#remove_probe_records(actor_id:, probe_registry:) ⇒ Array[String]

RBS:

  • (actor_id: String, probe_registry: ProcessRegistry) -> Array[String]

Parameters:

Returns:

  • (Array[String])


266
267
268
269
270
271
# File 'lib/solid_objects/doctor.rb', line 266

def remove_probe_records(actor_id:, probe_registry:)
  leftovers = []
  leftovers << "probe actor" unless delete_probe_actor(actor_id)
  leftovers << "probe caller process" unless delete_probe_caller_process(probe_registry)
  leftovers
end

#run_sync_probe(actor_id, probe_registry) ⇒ Check

RBS:

  • (String, ProcessRegistry) -> Check

Parameters:

Returns:



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/solid_objects/doctor.rb', line 246

def run_sync_probe(actor_id, probe_registry)
  probe_registry.register(kind: "caller", metadata: { execution: "doctor" })
  value = SecureRandom.hex(8)
  message_reference = Mailbox.new.enqueue(
    reference: ProbeActor.ref(actor_id),
    operation: :ping,
    arguments: { value: },
    delivery_mode: "sync"
  )
  result = SynchronousInvocation
    .new(process_registry: probe_registry)
    .call(message_reference, timeout: 5.seconds)
  raise Error, "unexpected round-trip result" unless result == value

  pass(:sync_round_trip, "durable synchronous actor call completed without a worker")
rescue => error
  fail_check(:sync_round_trip, "#{error.class}: #{error.message}")
end

#skip(name, message) ⇒ Check

RBS:

  • (Symbol, String) -> Check

Parameters:

  • (Symbol)
  • (String)

Returns:



361
362
363
# File 'lib/solid_objects/doctor.rb', line 361

def skip(name, message)
  Check.new(name:, status: :skip, message:)
end

#skipped_round_tripCheck

RBS:

  • () -> Check

Returns:



304
305
306
# File 'lib/solid_objects/doctor.rb', line 304

def skipped_round_trip
  skip(:sync_round_trip, "configuration or schema check failed")
end

#skipped_runtimeCheck

RBS:

  • () -> Check

Returns:



299
300
301
# File 'lib/solid_objects/doctor.rb', line 299

def skipped_runtime
  skip(:runtime, "schema check failed")
end

#warn_check(name, message) ⇒ Check

RBS:

  • (Symbol, String) -> Check

Parameters:

  • (Symbol)
  • (String)

Returns:



351
352
353
# File 'lib/solid_objects/doctor.rb', line 351

def warn_check(name, message)
  Check.new(name:, status: :warn, message:)
end