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 message_kind 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 message_name 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)


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

def configuration
  @configuration
end

#connectionObject (readonly)

Returns the value of attribute connection.

Returns:

  • (Object)


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

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
# 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,
    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:



156
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
# File 'lib/solid_objects/doctor.rb', line 156

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:



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

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

#check_runtimeCheck

RBS:

  • () -> Check

Returns:



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

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:



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

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:



216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/solid_objects/doctor.rb', line 216

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)


258
259
260
261
262
263
# File 'lib/solid_objects/doctor.rb', line 258

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)


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

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])


320
321
322
# File 'lib/solid_objects/doctor.rb', line 320

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:



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

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

#info(name, message) ⇒ Check

RBS:

  • (Symbol, String) -> Check

Parameters:

  • (Symbol)
  • (String)

Returns:



330
331
332
# File 'lib/solid_objects/doctor.rb', line 330

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

#pass(name, message) ⇒ Check

RBS:

  • (Symbol, String) -> Check

Parameters:

  • (Symbol)
  • (String)

Returns:



325
326
327
# File 'lib/solid_objects/doctor.rb', line 325

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]])


293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/solid_objects/doctor.rb', line 293

def policy_probes
  actor_arguments = {
    actor_type: ProbeActor.actor_type,
    actor_id: "doctor",
    authorization_context: nil
  }
  {
    authorize_message: actor_arguments.merge(
      message_name: "ping",
      arguments: { "value" => "doctor" }
    ),
    authorize_query: actor_arguments.merge(
      message_name: "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)


278
279
280
# File 'lib/solid_objects/doctor.rb', line 278

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])


250
251
252
253
254
255
# File 'lib/solid_objects/doctor.rb', line 250

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:



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/solid_objects/doctor.rb', line 230

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(
    ProbeActor.ref(actor_id),
    :ping,
    { value: },
    kind: "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:



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

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

#skipped_round_tripCheck

RBS:

  • () -> Check

Returns:



288
289
290
# File 'lib/solid_objects/doctor.rb', line 288

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

#skipped_runtimeCheck

RBS:

  • () -> Check

Returns:



283
284
285
# File 'lib/solid_objects/doctor.rb', line 283

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

#warn_check(name, message) ⇒ Check

RBS:

  • (Symbol, String) -> Check

Parameters:

  • (Symbol)
  • (String)

Returns:



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

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