Class: OpenLoam::ProgressJob

Inherits:
TenantRecord
  • Object
show all
Defined in:
app/models/open_loam/progress_job.rb

Overview

Progress of a long-running job (a bulk import, a reindex, a report) so the admin can watch it live instead of guessing. Tenant-scoped; a job started in one tenant is only visible and streamable there.

Deliberately NOT audited: progress is high-frequency churn (many advance ticks) and the audit trail would fill with noise. The terminal status and any error are captured on the row itself, which is the summary that matters.

Each meaningful change publishes open_loam.progress.updated (broadcastable → SSE) carrying only id/percent/status — no record contents. See OpenLoam::Progress for the entry point.

Constant Summary collapse

STATUSES =
%w[running completed failed cancelled].freeze
STALE_AFTER =
5.minutes

Instance Method Summary collapse

Instance Method Details

#advance(by: 1, message: nil) ⇒ Object

Increment progress. Persists every tick (so the count and heartbeat stay current) but THROTTLES the SSE broadcast to once per whole percent (or a message change) — a 10k-item job pushes ~100 frames, not 10k.



57
58
59
60
61
62
63
64
# File 'app/models/open_loam/progress_job.rb', line 57

def advance(by: 1, message: nil)
  before = percent
  self.completed = completed.to_i + by
  self.message = message if message
  save!
  broadcast if percent != before || message
  self
end

#cancel! ⇒ Object



74
75
76
# File 'app/models/open_loam/progress_job.rb', line 74

def cancel!
  finish!("cancelled")
end

#cancelled? ⇒ Boolean

A cooperative cancel: a long job calls this periodically and stops early. Re-reads the status column so an admin's cancel in ANOTHER request is seen without clobbering the in-memory counters.

Returns:

  • (Boolean)


81
82
83
# File 'app/models/open_loam/progress_job.rb', line 81

def cancelled?
  self.class.where(id: id).pick(:status) == "cancelled"
end

#complete! ⇒ Object



66
67
68
# File 'app/models/open_loam/progress_job.rb', line 66

def complete!
  finish!("completed") { self.completed = total if total.to_i.positive? }
end

#eta_seconds ⇒ Object

Seconds remaining, extrapolated from the rate so far, or nil when it can't be estimated yet (no progress, or already finished).



36
37
38
39
40
41
42
43
44
# File 'app/models/open_loam/progress_job.rb', line 36

def eta_seconds
  return nil unless running? && completed.to_i.positive? && started_at

  elapsed = Time.current - started_at
  rate = completed / elapsed # items per second
  return nil unless rate.positive?

  ((total - completed) / rate).round
end

#fail!(error_message = nil) ⇒ Object



70
71
72
# File 'app/models/open_loam/progress_job.rb', line 70

def fail!(error_message = nil)
  finish!("failed") { self.error = error_message.to_s.presence }
end

#percent ⇒ Object

Integer 0..100. A zero total is treated as 0% (an unknown-size job).



25
26
27
28
29
# File 'app/models/open_loam/progress_job.rb', line 25

def percent
  return 0 if total.to_i <= 0

  [ (completed.to_f / total * 100).floor, 100 ].min
end

#running? ⇒ Boolean

Returns:

  • (Boolean)


31
# File 'app/models/open_loam/progress_job.rb', line 31

def running? = status == "running"

#stale? ⇒ Boolean

A running job whose process died leaves the row "running" forever; it is stale once its heartbeat (updated_at, bumped on every advance) goes quiet. The prototype exposes the predicate and a manual mark-failed; a reaper daemon is the roadmap.

Returns:

  • (Boolean)


50
51
52
# File 'app/models/open_loam/progress_job.rb', line 50

def stale?
  running? && updated_at < STALE_AFTER.ago
end

#terminal? ⇒ Boolean

Returns:

  • (Boolean)


32
# File 'app/models/open_loam/progress_job.rb', line 32

def terminal? = !running?