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
- .attach_evidence(run:, expected_revision:, item_id:, kind:, value:, actor_ref: nil, now: Time.now.utc) ⇒ Object
- .close(run:, expected_revision:, outcome:, now: Time.now.utc) ⇒ Object
- .last(target:) ⇒ Object
-
.open(target:, actor_ref: nil, now: Time.now.utc) ⇒ Object
Insert FIRST, then rescue the unique violation and select the winner.
- .tick(run:, expected_revision:, item_id:, done:, actor_ref: nil, note: nil, now: Time.now.utc) ⇒ Object
Class Method Details
.attach_evidence(run:, expected_revision:, item_id:, kind:, value:, actor_ref: nil, now: Time.now.utc) ⇒ Object
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 |