Class: ChronoForge::BranchMergeJob
- Inherits:
-
ActiveJob::Base
- Object
- ActiveJob::Base
- ChronoForge::BranchMergeJob
- Defined in:
- lib/chrono_forge/branch_merge_job.rb
Overview
Lightweight poller that joins one or more branches. NOT a workflow — it holds no lock, does no replay, and carries no context. It exists so the heavy parent workflow is replayed only twice per merge (kick off + completion wake).
DEPLOY NOTE — queue placement matters. merge_branches enqueues this poller AFTER dispatching the branch's children, so if it runs on the SAME queue as a large fan-out's children it is starved behind the whole backlog and only gets a worker slot near the end. It then polls once, at pending≈0, with no prior sample (rate 0) and backs off to max_interval — so the parent's convergence lags by up to max_interval and no mid-drain throughput sample is ever recorded. Set ChronoForge.config.branch_merge_queue to a queue NOT saturated by the fan-out's own children so it polls throughout the drain (ETA cadence then converges tightly). See ChronoForge::Configuration and docs/fanout-scale-test.md.
Constant Summary collapse
- ETA_FRACTION =
poll at this fraction of the projected time-to-drain
0.5- REKICK_AFTER =
5.minutes
- REKICK_BATCH =
bound per-run rekicks; later polls handle the rest
200
Instance Method Summary collapse
Instance Method Details
#perform(parent_key, parent_job_class, branch_log_ids, min_interval, max_interval, token = nil) ⇒ Object
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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/chrono_forge/branch_merge_job.rb', line 38 def perform(parent_key, parent_job_class, branch_log_ids, min_interval, max_interval, token = nil) raise ArgumentError, "branch_log_ids must not be empty" if branch_log_ids.empty? # Fencing: every merge_branches pass mints a fresh token and writes it onto # the branch logs, so a poller from a superseded chain (parent replay / # re-enqueue) holds a stale token. It stops quietly — no poll, no wake, no # reschedule — leaving only the newest chain to drive the merge. (A nil token # is a pre-upgrade job enqueued before fencing existed; it runs unfenced.) logs = ExecutionLog.where(id: branch_log_ids).to_a return if superseded?(logs, token) # Per-branch probe (kept as maps so we can persist each branch's own state, # not just the merge aggregate). Same query count as a plain sum/all?. # The pending count is UNCAPPED: it feeds the drain signal below (a change in # pending since the prior poll), which a CAP would flatten into a false # "not draining" for large branches. prev_pending_by_branch = logs.to_h { |l| [l.id, l.&.dig("poll", "pending")] } pending_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.incomplete(id).count] } sealed_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.sealed?(id)] } pending = pending_by_branch.values.sum sealed = sealed_by_branch.values.all? # Total children spawned per branch. Immutable once the branch is SEALED # (dispatch done), so we count it exactly ONCE and cache it on the metadata; # every later poll (and the dashboard) reuses the cached value, never recounting. # Unsealed (mid-spawn, count still climbing) => nil, and the dashboard falls back # to its capped live count until the seal freezes the total. logs_by_id = logs.index_by(&:id) spawned_by_branch = branch_log_ids.to_h do |id| cached = logs_by_id[id]&.&.dig("poll", "spawned") [id, cached || (sealed_by_branch[id] ? BranchProbe.spawned(id).count : nil)] end if sealed && pending.zero? record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: nil, interval: nil, rate_by_branch: {}, never_started_by_branch: {}, spawned_by_branch: spawned_by_branch, rekicked_by_branch: {}) parent_job_class.constantize.perform_later(parent_key) return end # DISPATCHED (never-started) count per branch — the rekick drain signal. A # drop since the prior poll means workers are consuming this branch's queue, # so a still-queued child is in line; a flat count with stale never-started # children is a dropped job to recover. Keyed off this, NOT total pending, # which a wait/wait_until child completing would drop without any never-started # child moving (masking a genuinely-dropped one behind staggered waits). prev_never_started_by_branch = logs.to_h { |l| [l.id, l.&.dig("poll", "never_started")] } never_started_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.never_started(id).count] } rekicked_by_branch = rekick_dropped_jobs(branch_log_ids, never_started_by_branch, prev_never_started_by_branch) # Cadence is driven by ESTIMATED TIME-TO-DRAIN, measured from the prior # poll's persisted pending. `motion` (EXISTS probes) is the fallback signal # when nothing completed this interval: :running => a live worker is # executing a child (hold the floor, it'll finish); :never_started => the only # motion is a queued/rekicked-but-unpicked child (back off exponentially, # it may never be picked up); :none => blocked/waiting (max backstop). # See reschedule_delay. Computed lazily below, only off the drain path. prior = logs.map { |l| l.&.dig("poll") } # Only trust the AGGREGATE prev_pending when every requested branch log is # loaded AND carries a prior sample — otherwise `pending` (over all # branch_log_ids) and prev_pending (over loaded logs) would cover different # sets and yield a bogus aggregate rate. Missing/partial => no sample => # bootstrap. Per-branch rate below is independently safe (missing => nil => 0). complete_prior = logs.size == branch_log_ids.size && prior.all? prev_pending = (prior.sum { |p| p["pending"].to_i } if complete_prior) prev_polled_at = prior.filter_map { |p| p && p["last_polled_at"] }.map { |s| Time.zone.parse(s) }.min elapsed = prev_polled_at && (Time.current - prev_polled_at) prev_delay = prior.filter_map { |p| p && p["interval"] }.max # Drain rate = children completed / second since the prior poll — THIS is the # throughput surfaced on the dashboard. Per branch for display; aggregated for # the ETA. Zero unless the branch actually drained (a no-headway / cold poll). # NOTE: the aggregate ETA blurs a heterogeneous multi-branch merge; acceptable # (the common case is single-branch; clamp + per-poll re-estimate bound any # skew, and only poll timing is affected — the parent is still woken). drained = ->(pend, prev) { prev && elapsed && elapsed > 0 && pend < prev } rate_by_branch = pending_by_branch.to_h do |id, pend| prev = prev_pending_by_branch[id] [id, drained.call(pend, prev) ? (prev - pend) / elapsed.to_f : 0.0] end rate = drained.call(pending, prev_pending) ? (prev_pending - pending) / elapsed.to_f : 0.0 # Only needed when the ETA branch won't be taken (rate == 0); computing the # EXISTS probes lazily keeps them off the hot drain path. See reschedule_delay. motion = if rate > 0 then nil elsif branch_log_ids.any? { |id| BranchProbe.running?(id) } then :running elsif never_started_by_branch.values.any?(&:positive?) then :never_started else :none end delay = reschedule_delay(pending, rate, motion, prev_delay, min_interval, max_interval) record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: delay.seconds.from_now, interval: delay, rate_by_branch: rate_by_branch, never_started_by_branch: never_started_by_branch, spawned_by_branch: spawned_by_branch, rekicked_by_branch: rekicked_by_branch) self.class.set(wait: delay.seconds) .perform_later(parent_key, parent_job_class, branch_log_ids, min_interval, max_interval, token) end |