kioku-core-0.1.0.0: src/Kioku/Distill/Timer/Outcome.hs
-- | What a distillation timer fire decided.
--
-- This module sits at the bottom of the distillation graph so that
-- "Kioku.Distill.L2", "Kioku.Distill.L3", and "Kioku.Distill.Timer.Worker" can
-- all import it (the worker imports L2 and L3, so the type cannot live there).
--
-- It replaces a @Maybe EventId@ whose @Nothing@ meant three incompatible things
-- at once — a transient failure, a permanent failure, and "this timer is not
-- mine" — all of which keiro treated identically by leaving the row @firing@
-- until its 300-second stale-claim requeue. A timer whose LLM call could never
-- succeed therefore retried forever, at full LLM cost, with nothing to show for
-- it.
module Kioku.Distill.Timer.Outcome
( FireOutcome (..),
fireRetryDelay,
unknownTimerRetryDelay,
timerMarkerEventId,
)
where
import Data.Time (NominalDiffTime)
import Keiro.Timer (TimerId (..))
import Kioku.Prelude
import Kiroku.Store.Types (EventId (..))
-- | The verdict of one fire attempt.
data FireOutcome
= -- | Done. Mark the timer fired with this event id.
FireCompleted !EventId
| -- | Transient failure: reschedule this many seconds out, logging the note.
-- Bounded by keiro's attempt ceiling, so a persistently failing timer ends
-- as a visible @dead@ row rather than cycling forever.
FireRetryLater !NominalDiffTime !Text
| -- | This can never succeed (a corrupt payload, an unparseable correlation
-- id). Dead-letter it with this reason instead of faking success.
FireFailedPermanently !Text
| -- | This timer's process manager is not mine; I did not touch the row.
FireNotMine
deriving stock (Generic, Eq, Show)
-- | Backoff for a transient fire failure, by post-claim attempt count:
-- 30s, 60s, 120s, … doubling, capped at 900s.
--
-- keiro increments @attempts@ at claim time, so the first failure is called with
-- @1@. Eight claims under this schedule span roughly an hour — long enough to
-- ride out a provider incident, short enough to stop burning LLM tokens on work
-- that is structurally broken.
fireRetryDelay :: Int -> NominalDiffTime
fireRetryDelay attempts =
min 900 (30 * (2 ^ max 0 (attempts - 1)))
-- | Requeue delay for a timer no handler owns.
--
-- keiro's 'Keiro.Timer.claimDueTimer' claims the earliest due timer regardless
-- of process-manager name, so an unknown-PM timer cannot be left unclaimed — it
-- must be put back. It is deliberately not dead-lettered on sight: during a
-- rolling deploy a newer kioku may have scheduled timers this binary does not
-- know about yet, and ten minutes gives the newer worker time to take them. The
-- attempt ceiling still guarantees a genuinely orphaned timer dies visibly.
unknownTimerRetryDelay :: NominalDiffTime
unknownTimerRetryDelay = 600
-- | Distillation timers append no dedicated domain event, so a fired timer is
-- marked with its own id as the event id. Keeping the convention in one place
-- means the three fire handlers cannot drift apart on it.
timerMarkerEventId :: TimerId -> EventId
timerMarkerEventId (TimerId uuid) = EventId uuid