Class: Insika::Recovery

Inherits:
Object
  • Object
show all
Defined in:
lib/insika/recovery.rb

Overview

Called ONCE at boot, BEFORE accepting requests. Discovers interrupted tasks and dispatches the resume through the SAME path as ResumeTask — this component executes nothing, opens no Execution, changes no status of resumable tasks. Just discovery + dispatch + marking of unrecoverable tasks.

Durability without an external job runner = stores + recovery at boot. The "execution" half is the ResumeTask handler.

command_bus is consumed only through the dispatch(command) contract.

Constant Summary collapse

SWEEP_SCOPE =
"recovery"

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(task_store:, checkpoint_store:, command_bus:, logger: nil) ⇒ Recovery

checkpoint_store: needed to query latest. logger optional (default nil -> silent in tests).



45
46
47
48
49
50
# File 'lib/insika/recovery.rb', line 45

def initialize(task_store:, checkpoint_store:, command_bus:, logger: nil)
  @task_store = task_store
  @checkpoint_store = checkpoint_store
  @command_bus = command_bus
  @logger = logger
end

Class Method Details

.claim_sweep(store:, boot_id:) ⇒ Object

The per-boot-generation sweep claim (RFC-0016 E2). N workers share one store, and the sweep's "orphaned :running" test is per-process: a worker booting while a sibling holds a live turn would see it as an orphan and re-run it. So the TASK sweep runs once per boot generation — the first worker to claim boot_id sweeps, the rest skip; a worker respawned mid-generation skips too (its own orphans wait for the next generation). Rides Store#transaction like every claim. nil/empty boot_id (single process: DSL serve, scripts, tests) -> always true, every boot sweeps.



28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/insika/recovery.rb', line 28

def self.claim_sweep(store:, boot_id:)
  id = boot_id.to_s
  return true if id.empty?

  store.transaction do
    key = "sweep:#{id}"
    if store.get(SWEEP_SCOPE, key).nil?
      store.set(SWEEP_SCOPE, key, { "claimed_at" => Time.now.utc.iso8601 })
      true
    else
      false
    end
  end
end

Instance Method Details

#runObject

-> { resumed: [ids], failed: [ids] } The initial sweep runs OUTSIDE the per-task rescue: a StoreError here aborts the boot.



55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/insika/recovery.rb', line 55

def run
  resumed = []
  failed = []
  # Ordered by created_at: tasks from the SAME session are reprocessed
  # in their original order. Global time ordering is harmless for standalone tasks.
  # 1) interrupted (crash mid-flight) -> resume from the checkpoint.
  @task_store.running_or_interrupted.sort_by(&:created_at).each { |task| process(task, resumed, failed) }
  # 2) queued but never started (turn in the SessionActor queue at crash time)
  #    -> re-run from scratch (the same resume_task handles :queued). Without
  #    this, a :queued turn in the volatile queue would be lost on kill -9.
  @task_store.queued.sort_by(&:created_at).each { |task| process(task, resumed, failed) }
  log(:info, "recovery finished: #{resumed.size} resumed, #{failed.size} failed")
  { resumed: resumed, failed: failed }
end