Module: Jazari::Runs

Defined in:
lib/jazari/runs.rb

Overview

The Run layer.

Idempotency is a property of the RECIPE, not a global rule: verifying a backup should happen once a day; triaging an incident may happen five times. Under once_per_calendar_day a partial unique index over (recipe_id, COALESCE(subject_type,''), COALESCE(subject_id,0), started_on) enforces it — the COALESCE matters because a bare NULL subject (a queue run) would otherwise be unconstrained, since NULL != NULL in a unique index.

Constant Summary collapse

EVIDENCE_KINDS =

-- internals ---------------------------------------------------------

%w[output url sha count note].freeze
MAX_EVIDENCE =
2_000

Class Method Summary collapse

Class Method Details

.attach_evidence(run:, expected_revision:, item_id:, kind:, value:, actor_ref: nil, now: Time.now.utc) ⇒ Object

Raises:



98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/jazari/runs.rb', line 98

def attach_evidence(run:, expected_revision:, item_id:, kind:, value:, actor_ref: nil,
                    now: Time.now.utc)
  raise InvalidRunbook, "unknown evidence kind #{kind.inspect}" unless EVIDENCE_KINDS.include?(kind.to_s)

  mutate(run, expected_revision) do |record|
    raise RunClosed, "run #{record.id} is already closed" if record.closed?

    record.evidence = stored(record.evidence) + [
      { "item_id" => item_id&.to_s, "kind" => kind.to_s,
        "value" => value.to_s[0, MAX_EVIDENCE], "at" => now.utc.iso8601,
        "actor_ref" => resolve_actor_ref(actor_ref, fallback: record.actor_ref) }
    ]
  end
end

.close(run:, expected_revision:, outcome:, now: Time.now.utc) ⇒ Object



113
114
115
116
117
118
119
120
121
# File 'lib/jazari/runs.rb', line 113

def close(run:, expected_revision:, outcome:, now: Time.now.utc)
  mutate(run, expected_revision) do |record|
    raise RunClosed, "run #{record.id} is already closed" if record.closed?
    raise InvalidRunbook, "unknown outcome #{outcome.inspect}" unless Run::OUTCOMES.include?(outcome.to_s)

    record.outcome = outcome.to_s
    record.finished_at = now.utc
  end
end

.last(target:) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/jazari/runs.rb', line 123

def last(target:)
  recipe_id = target.recipe_id.to_s
  subject = subject_for(target, strict: false)
  # An anchor target whose anchor does not exist yet cannot have runs. It
  # must NOT fall through to the nil-subject branch, which would return
  # queue runs belonging to a different target entirely.
  return nil if target.is_a?(AnchorTarget) && subject.nil?

  scope = Run.where(recipe_id: recipe_id)
  scope = if subject
    scope.where(subject_type: subject.class.name, subject_id: subject.id)
  else
    scope.where(subject_type: nil, subject_id: nil)
  end
  scope.order(started_at: :desc).first
end

.open(target:, actor_ref: nil, now: Time.now.utc) ⇒ Object

Insert FIRST, then rescue the unique violation and select the winner. A find-then-insert races under concurrent writers — the same defect class the revision guard exists to prevent.



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/jazari/runs.rb', line 21

def open(target:, actor_ref: nil, now: Time.now.utc)
  recipe = RecipeRegistry.fetch(target.recipe_id)
  resolved = Operations.resolve(target: target)
  subject = subject_for(target)
  started_at = now.utc
  actor_ref = resolve_actor_ref(actor_ref)

  attributes = {
    recipe_id: recipe.id,
    source_digest: recipe.digest,
    subject_type: subject&.class&.name,
    subject_id: subject&.id,
    actor_ref: actor_ref.to_s,
    started_at: started_at,
    # The UTC calendar day. Never server-local: one nightly ritual must not
    # land on two different days depending on which region's box called it.
    started_on: started_at.to_date,
    idempotency_policy: recipe.run_policy,
    # The run is bound to the canon it opened against. Without this, an
    # operator editing a recipe mid-run makes the in-flight run unable to
    # tick its own steps, and able to tick steps that did not exist when it
    # started. source_digest alone is provenance, not protection.
    # A subject may carry a deliberate runbook customization. The run must
    # snapshot the truth the caller resolved, not silently fall back to the
    # recipe and then reject the subject's own checklist item at tick time.
    checklist_snapshot: resolved.checklist.map { |i| i.transform_keys(&:to_s) },
    ticks: [], evidence: []
  }

  attempts = 0
  begin
    attempts += 1
    run = Run.create!(attributes)
    { run: run, created: true, idempotent_reuse: false }
  rescue ActiveRecord::RecordNotUnique => error
    # Only a once-per-day recipe can collide on the idempotency index. Any
    # other unique violation belongs to a constraint we do not own — a host
    # index, say — and must never be reinterpreted as reuse.
    raise unless recipe.once_per_calendar_day?

    existing = find_days_run(attributes)
    # The winner can be deleted between our failed INSERT and this SELECT.
    # Retry once: on the second pass the row is gone and the INSERT wins.
    retry if existing.nil? && attempts < 2

    # No row on the idempotency key means this violation came from some
    # OTHER constraint. Surfacing our own error here would hide the host's
    # real one, so re-raise theirs untouched.
    raise error if existing.nil?

    { run: existing, created: false, idempotent_reuse: true }
  end
end

.tick(run:, expected_revision:, item_id:, done:, actor_ref: nil, note: nil, now: Time.now.utc) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/jazari/runs.rb', line 75

def tick(run:, expected_revision:, item_id:, done:, actor_ref: nil, note: nil, now: Time.now.utc)
  mutate(run, expected_revision) do |record|
    raise RunClosed, "run #{record.id} is already closed" if record.closed?

    snapshot = stored(record.checklist_snapshot)
    if snapshot.empty?
      # Every run written by this gem carries its opening checklist. An
      # empty one means the row predates the snapshot column, so any
      # migration that adds it must backfill rather than default to [].
      raise InvalidRunbook, "run #{record.id} has no checklist snapshot; backfill required"
    end

    known = snapshot.map { |item| item["id"] }
    raise ItemNotFound, "unknown checklist item #{item_id}" unless known.include?(item_id.to_s)

    ticks = stored(record.ticks).reject { |t| t["id"] == item_id.to_s }
    ticks << { "id" => item_id.to_s, "done" => done == true, "at" => now.utc.iso8601,
               "actor_ref" => resolve_actor_ref(actor_ref, fallback: record.actor_ref),
               "note" => note&.to_s }
    record.ticks = ticks
  end
end