Class: Insika::Tick

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

Overview

The periodic tick: durability stops waiting for a reboot. One pass does two things, in this order:

1. DRAIN the outbox (`ChannelDelivery#sweep`) — replies a previous pass
 (or process) recorded and never claimed. Ungated: every record carries
 its own transactional claim, so N workers draining is safe.
2. SWEEP stale orphaned tasks (`Recovery#run(stale_after:)`) — gated by a
 bucketed claim (`Recovery.claim_sweep` on "tick:<epoch/interval>"), so
 exactly one worker per window sweeps. The staleness threshold is the
 liveness gate: a live :running turn is bounded by turn_timeout, so
 anything untouched past it cannot be alive.

It is NOT a job queue: no schedules, no priorities, no fan-out. The refinement hook once pictured here is dropped by merit — docs/REFINEMENT.md's "no scheduler in the engine" stands.

Constant Summary collapse

DEFAULT_INTERVAL =

60s: a customer waiting on WhatsApp is the deadline. 900s = 3x the default turn_timeout (300s) — the rule, not the number: the threshold must exceed the deployment's largest turn_timeout, or the sweep would judge live turns orphaned.

60
DEFAULT_STALE_AFTER =
900
SCOPE =
"tick"
KEY =
"claim"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(store:, recovery:, channel_delivery:, logger: nil, interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER, sleeper: nil, retention: nil, funnel: nil, followup: nil) ⇒ Tick

Returns a new instance of Tick.



32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/insika/tick.rb', line 32

def initialize(store:, recovery:, channel_delivery:, logger: nil,
               interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER,
               sleeper: nil, retention: nil, funnel: nil, followup: nil)
  @store = store
  @recovery = recovery
  @channel_delivery = channel_delivery
  @logger = logger
  @interval = interval.to_i
  @stale_after = stale_after.to_i
  @sleeper = sleeper || method(:default_sleep)
  @retention = retention # WS8: the daily age-based sweep; nil = none
  @funnel = funnel # the tick-driven outcome fold; nil = none
  @followup = followup # the tick-driven follow-up firer; nil = none
end

Instance Attribute Details

#followupObject

the follow-up firer, wired after the Tick is built (same shape as funnel — the stores come from the spine).



54
55
56
# File 'lib/insika/tick.rb', line 54

def followup
  @followup
end

#funnelObject

the fold is wired after the Tick is built (the graph passes it to executor.tick.funnel = — the outcome/funnel stores come from the spine). Setter + kwarg: same shape as retention.



50
51
52
# File 'lib/insika/tick.rb', line 50

def funnel
  @funnel
end

Instance Method Details

#enabled?Boolean

Returns:

  • (Boolean)


56
# File 'lib/insika/tick.rb', line 56

def enabled? = @interval.positive?

#run_onceObject

One pass, pure (no reactor needed): the serving loop calls it on a timer, specs call it directly. A StoreError propagates to the loop, which logs and keeps ticking.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/insika/tick.rb', line 61

def run_once
  drained = @channel_delivery ? @channel_delivery.sweep : { dispatched: [] }
  summary = { dispatched: drained[:dispatched], resumed: [], failed: [] }
  # WS8 retention: cheap when not claimed (its own daily window) — the
  # O(n) scans never ride the 60 s loop.
  retention_summary = @retention&.run
  summary[:retention] = retention_summary if retention_summary
  # the outcome fold — one pass per claim window, cheap
  # when another worker holds it. Sits next to retention, on the same tick.
  funnel_summary = @funnel&.run
  summary[:funnel] = funnel_summary if funnel_summary
  # the follow-up firer — the tick's third duty, gated by its
  # OWN claim window so the O(n) scans never ride the 60 s loop.
  followup_summary = @followup&.run
  summary[:followup] = followup_summary if followup_summary
  return summary unless claim_window

  result = @recovery.run(stale_after: @stale_after)
  summary.merge(resumed: result[:resumed], failed: result[:failed])
end

#start(parent:) ⇒ Object

The loop, spawned as a child of the turn supervisor (the tick lives on the supervisor fiber — every serving arm gets it the moment supervised = true matters, with no arm edits). A failing pass logs and the loop continues: a sweeper that dies silently is the outage it exists to prevent. Restartable: when the supervisor is recreated its children died with it, so a stopped task is not a running one.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/insika/tick.rb', line 88

def start(parent:)
  return false unless enabled?
  return true if @task&.running?

  @task = parent.async do |t|
    t.annotate("insika-tick")
    loop do
      @sleeper.call(@interval)
      run_once
    rescue StandardError => e
      log(:warn, "tick failed: #{e.class}: #{e.message}")
    end
  end
  true
end