kioku-core-0.5.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 (..),
parsePartitionedScopeFields,
firePartitionedDistillTimer,
fireRetryDelay,
unknownTimerRetryDelay,
timerMarkerEventId,
)
where
import Data.Aeson.Types (Object, Parser, parseEither, withObject, (.:))
import Data.Text qualified as Text
import Data.Time (NominalDiffTime)
import Effectful (Eff)
import Keiro.Timer (TimerId (..), TimerRow (..))
import Kioku.Api.Access
( MemoryContextProvider (..),
MemoryPermission (MemoryDistill),
MemorySpaceId,
memoryContextAllows,
memoryContextSpace,
memorySpaceIdText,
)
import Kioku.Api.Scope (MemoryScope)
import Kioku.Partition (parsePartitionSpace)
import Kioku.Prelude
import Kiroku.Store.Types (EventId (..))
-- | Decode the partition and scope shared by L2 and L3 timer payloads.
--
-- 'parsePartitionSpace' deliberately keeps native pre-partition timers working by defaulting a
-- missing @memorySpaceId@ into the legacy space.
parsePartitionedScopeFields :: Object -> Parser (MemorySpaceId, MemoryScope)
parsePartitionedScopeFields o =
(,) <$> parsePartitionSpace o <*> o .: "scope"
-- | Run the common authorization and outcome pipeline for one derived-artifact timer.
--
-- Provider refusal, an under-scoped context, a context for the wrong space, and malformed input
-- are configuration facts, so they dead-letter. Only a regeneration failure is retryable.
firePartitionedDistillTimer ::
(Show err) =>
Text ->
String ->
MemoryContextProvider (Eff es) ->
TimerRow ->
(MemorySpaceId -> MemoryScope -> Eff es (Either err result)) ->
Eff es FireOutcome
firePartitionedDistillTimer expectedProcessName payloadLabel contextProvider row regenerate
| row.processManagerName /= expectedProcessName =
pure FireNotMine
| otherwise =
case parseEither (withObject (payloadLabel <> " payload") parsePartitionedScopeFields) row.payload of
Left err ->
pure (FireFailedPermanently (label <> " payload is malformed: " <> Text.pack err))
Right (requestedSpace, scope) -> do
decision <- contextProvider.contextForSpace requestedSpace
case decision of
Left denial ->
pure
( FireFailedPermanently
(label <> " is not authorized for its memory space: " <> Text.pack (show denial))
)
Right context
| memoryContextSpace context /= requestedSpace ->
pure
( FireFailedPermanently
( label
<> " context provider returned memory space "
<> memorySpaceIdText (memoryContextSpace context)
<> " for requested space "
<> memorySpaceIdText requestedSpace
)
)
| not (memoryContextAllows MemoryDistill context) ->
pure
( FireFailedPermanently
(label <> " context is missing required permission MemoryDistill")
)
| otherwise -> do
result <- regenerate (memoryContextSpace context) scope
pure case result of
Right _ -> FireCompleted (timerMarkerEventId row.timerId)
Left err -> FireRetryLater (fireRetryDelay row.attempts) (Text.pack (show err))
where
label = Text.pack payloadLabel
-- | 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