Class: Insika::Tick
- Inherits:
-
Object
- Object
- Insika::Tick
- 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
-
#followup ⇒ Object
the follow-up firer, wired after the Tick is built (same shape as
funnel— the stores come from the spine). -
#funnel ⇒ Object
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).
Instance Method Summary collapse
- #enabled? ⇒ Boolean
-
#initialize(store:, recovery:, channel_delivery:, logger: nil, interval: DEFAULT_INTERVAL, stale_after: DEFAULT_STALE_AFTER, sleeper: nil, retention: nil, funnel: nil, followup: nil) ⇒ Tick
constructor
A new instance of Tick.
-
#run_once ⇒ Object
One pass, pure (no reactor needed): the serving loop calls it on a timer, specs call it directly.
-
#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 = truematters, with no arm edits).
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
#followup ⇒ Object
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 |
#funnel ⇒ Object
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
56 |
# File 'lib/insika/tick.rb', line 56 def enabled? = @interval.positive? |
#run_once ⇒ Object
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.}") end end true end |