packages feed

kioku-core-0.4.0.0: test/Kioku/TimerWorkerSpec.hs

{-# LANGUAGE DataKinds #-}

module Kioku.TimerWorkerSpec
  ( tests,
  )
where

import Data.Aeson qualified as Aeson
import Data.Functor.Contravariant ((>$<))
import Data.Int (Int64)
import Data.Text qualified as Text
import Data.Time (NominalDiffTime, addUTCTime, diffUTCTime)
import Data.UUID qualified as UUID
import Data.UUID.V4 qualified as UUIDv4
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Hasql.Transaction qualified as Tx
import Keiro.Timer (TimerId (..), TimerRequest (..), scheduleTimerTx)
import Kioku.Api.Access
  ( MemoryAccessContext,
    MemoryAccessDenial (..),
    MemoryContextProvider (..),
    MemoryPermission (..),
    MemorySpaceId,
    legacyMemorySpaceId,
    memoryContextRecordedActor,
    memorySpaceIdText,
  )
import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))
import Kioku.App (AppEffects, AppEnv, runAppIO, withNoopAppEnv)
import Kioku.Distill.L1 (scopedScanCandidates)
import Kioku.Distill.L2 (SceneTimerPayload (..), l2SceneProcessManagerName, l2SceneTimerId)
import Kioku.Distill.L3 (partitionedCorrelationId)
import Kioku.Distill.Runtime (DistillRuntime (..), newDistillRuntime)
import Kioku.Distill.Timer (L1TimerPayload (..), l1ExtractProcessManagerName)
import Kioku.Distill.Timer.Worker (drainKiokuTimers, runKiokuTimerWorkerOnce)
import Kioku.Id (SessionId, genSessionId, idText)
import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)
import Kioku.Prelude
import Kioku.Session qualified as Session
import Kioku.Session.Domain (StartSessionData (..))
import Kioku.SpaceFixtures (legacyContext, otherSpace, testContext, testContextProvider, testSpace)
import Kiroku.Store.Connection (defaultConnectionSettings)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Transaction (runTransaction)
import Shibuya.Telemetry.Effect (Tracing)
import Shikumi.Error (ShikumiError (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "Timer worker"
    [ testCase "permanent failure dead-letters the timer" testPermanentFailureDeadLetters,
      testCase "transient failure reschedules with backoff" testTransientFailureReschedules,
      testCase "a timer scheduled before memory spaces fires in the legacy space" testPrePartitionPayloadFiresInLegacySpace,
      testCase "a pre-partition timer cannot reach a session in another space" testPrePartitionPayloadCannotReachAnotherSpace,
      testCase "a malformed L1 payload dead-letters" testMalformedL1PayloadDeadLetters,
      testCase "unknown process manager requeues with a long delay" testUnknownProcessManagerRequeues,
      testCase "the attempt ceiling dead-letters" testAttemptCeilingDeadLetters,
      testCase "success marks the timer fired" testSuccessMarksFired,
      testCase "drain processes every due timer in one pass" testDrainProcessesAllDueTimers,
      testCase "two spaces sharing a scope schedule two timers, and both fire" testTwoSpacesTwoTimers,
      testCase "a refused memory space dead-letters" testRefusedSpaceDeadLetters,
      testCase "every dead-letter row names the memory space" testDeadLetterNamesTheSpace
    ]

-- | A correlation id that is not a session id can never become one. It used to
-- be marked fired — a fake success that lost the distillation silently.
testPermanentFailureDeadLetters :: Assertion
testPermanentFailureDeadLetters =
  withTimerEnv \env rt -> do
    timerId <- freshTimerId
    row <- runOrFail env do
      scheduleTestTimer timerId l1ExtractProcessManagerName "not-a-session-id" Aeson.Null (-1)
      fireOnce rt
      fetchTimer timerId
    row.status @?= "dead"
    assertBool
      ("last_error names the correlation id, got: " <> show row.lastError)
      (maybe False (Text.isInfixOf "correlation id") row.lastError)

-- | A failing LLM extraction is worth retrying — but on a schedule, and only
-- until the ceiling. The timer must land back in @scheduled@ with @fire_at@
-- pushed out, not sit in @firing@ waiting on keiro's 300-second stale requeue.
testTransientFailureReschedules :: Assertion
testTransientFailureReschedules =
  withTimerEnv \env rt -> do
    timerId <- freshTimerId
    sid <- genSessionId
    let failing = rt {runExtract = \_ -> pure (Left (ProviderFailure "the model is down"))}
    before <- getCurrentTime
    row <- runOrFail env do
      startFixtureSession sid
      scheduleTestTimer timerId l1ExtractProcessManagerName (idText sid) (l1Payload testSpace) (-1)
      fireOnce failing
      fetchTimer timerId
    row.status @?= "scheduled"
    -- keiro increments attempts at claim time, so one claim means attempts = 1,
    -- and the first backoff step is 30s.
    row.attempts @?= 1
    assertDelayNear "first retry" before 30 row.fireAt

-- | An L1 timer written before memory spaces existed has no @memorySpaceId@ in its payload.
--
-- It must still fire, in the legacy space, exactly like a stored event with no partition. The
-- proof is indirect but decisive: the pass runs (and here fails on the stubbed extractor, so the
-- timer is rescheduled) rather than dead-lettering, which is what an unreadable payload or a
-- refused space would do.
--
-- The session lives in the legacy space too, because that is the only arrangement a
-- pre-partition database can produce. The companion case below is what happens when it does
-- not.
testPrePartitionPayloadFiresInLegacySpace :: Assertion
testPrePartitionPayloadFiresInLegacySpace =
  withTimerEnv \env rt -> do
    timerId <- freshTimerId
    sid <- genSessionId
    let failing = rt {runExtract = \_ -> pure (Left (ProviderFailure "the model is down"))}
    row <- runOrFail env do
      startFixtureSessionIn legacyContext legacyMemorySpaceId sid
      scheduleTestTimer timerId l1ExtractProcessManagerName (idText sid) prePartitionL1Payload (-1)
      fireOnce failing
      fetchTimer timerId
    row.status @?= "scheduled"

-- | The same pre-partition timer, against a session that belongs to another space.
--
-- The pass looks the session up in the legacy space, does not find it, and treats it as a
-- session that is gone — which marks the timer fired. That is the right outcome and the
-- important one: a timer defaulted into the legacy space must never reach into a space that
-- was created after it.
testPrePartitionPayloadCannotReachAnotherSpace :: Assertion
testPrePartitionPayloadCannotReachAnotherSpace =
  withTimerEnv \env rt -> do
    timerId <- freshTimerId
    sid <- genSessionId
    let failing = rt {runExtract = \_ -> liftIO (assertFailure "the extractor must not run")}
    row <- runOrFail env do
      startFixtureSession sid
      scheduleTestTimer timerId l1ExtractProcessManagerName (idText sid) prePartitionL1Payload (-1)
      fireOnce failing
      fetchTimer timerId
    row.status @?= "fired"

-- | An L1 timer payload as it was written before memory spaces existed.
prePartitionL1Payload :: Aeson.Value
prePartitionL1Payload =
  Aeson.object ["kind" Aeson..= ("idle" :: Text), "turnCount" Aeson..= (1 :: Int)]

-- | A payload this handler cannot read will not become readable on the next attempt, so it
-- dead-letters where an operator can see it rather than retrying for an hour first.
testMalformedL1PayloadDeadLetters :: Assertion
testMalformedL1PayloadDeadLetters =
  withTimerEnv \env rt -> do
    timerId <- freshTimerId
    sid <- genSessionId
    row <- runOrFail env do
      startFixtureSession sid
      scheduleTestTimer timerId l1ExtractProcessManagerName (idText sid) Aeson.Null (-1)
      fireOnce rt
      fetchTimer timerId
    row.status @?= "dead"
    assertBool
      ("last_error names the payload, got: " <> show row.lastError)
      (maybe False (Text.isInfixOf "payload") row.lastError)

-- | An L1 timer payload as the projection writes one today.
l1Payload :: MemorySpaceId -> Aeson.Value
l1Payload space =
  Aeson.toJSON L1TimerPayload {kind = "idle", turnCount = Just 1, memorySpaceId = space}

-- | keiro's claimDueTimer claims the earliest due timer regardless of process
-- manager, so a timer no handler owns cannot be left alone — it must be put
-- back, or it starves every other timer behind it forever.
testUnknownProcessManagerRequeues :: Assertion
testUnknownProcessManagerRequeues =
  withTimerEnv \env rt -> do
    timerId <- freshTimerId
    before <- getCurrentTime
    row <- runOrFail env do
      scheduleTestTimer timerId "kioku-nonexistent" "whatever" Aeson.Null (-1)
      fireOnce rt
      fetchTimer timerId
    row.status @?= "scheduled"
    row.attempts @?= 1
    assertDelayNear "unknown-PM requeue" before 600 row.fireAt

-- | The requeue above is bounded: an orphaned timer eventually dies visibly
-- instead of cycling forever. keiro applies the ceiling at claim time.
testAttemptCeilingDeadLetters :: Assertion
testAttemptCeilingDeadLetters =
  withTimerEnv \env rt -> do
    timerId <- freshTimerId
    row <- runOrFail env do
      scheduleTestTimer timerId "kioku-nonexistent" "whatever" Aeson.Null (-1)
      forceAttempts timerId 8
      fireOnce rt
      fetchTimer timerId
    row.status @?= "dead"
    assertBool
      ("last_error mentions the ceiling, got: " <> show row.lastError)
      (maybe False (Text.isInfixOf "attempt ceiling") row.lastError)

-- | The happy path: an L2 timer for a scope with no memories regenerates
-- nothing, succeeds without calling the LLM, and is marked fired with the timer's
-- own id as the marker event id.
testSuccessMarksFired :: Assertion
testSuccessMarksFired =
  withTimerEnv \env rt -> do
    timerId@(TimerId timerUuid) <- freshTimerId
    row <- runOrFail env do
      scheduleTestTimer
        timerId
        l2SceneProcessManagerName
        "rei/intention/empty"
        (Aeson.object ["scope" Aeson..= emptyScope])
        (-1)
      fireOnce rt
      fetchTimer timerId
    row.status @?= "fired"
    row.firedEventId @?= Just (UUID.toText timerUuid)

-- | The old loop slept between every timer, so N due timers took N poll
-- intervals. Draining processes them all in one pass.
testDrainProcessesAllDueTimers :: Assertion
testDrainProcessesAllDueTimers =
  withTimerEnv \env rt -> do
    timerIds <- traverse (const freshTimerId) [1 :: Int, 2, 3]
    (processed, rows) <- runOrFail env do
      forM_ timerIds \timerId ->
        scheduleTestTimer timerId l1ExtractProcessManagerName "not-a-session-id" Aeson.Null (-1)
      processed <- drainKiokuTimers Nothing testContextProvider rt (scopedScanCandidates 5)
      rows <- traverse fetchTimer timerIds
      pure (processed, rows)
    processed @?= 3
    fmap (.status) rows @?= ["dead", "dead", "dead"]

-- | Two L2 scene timers for the same namespace and scope, one per memory space.
--
-- The timer id is a UUIDv5 over the process manager, the correlation id, and a source id, and
-- the correlation id is @\<space\>:\<scope identity\>@. Without the space in there both spaces
-- derive one id, and keiro's scheduling upsert would treat the second schedule as a re-arming of
-- the first — one timer, one payload, one space's scene regenerated and the other's silently
-- dropped. Two distinct ids, two rows, and both fired is the whole invariant.
testTwoSpacesTwoTimers :: Assertion
testTwoSpacesTwoTimers =
  withTimerEnv \env rt -> do
    let mineTimer = l2SceneTimerId testSpace emptyScope "shared-source"
        theirsTimer = l2SceneTimerId otherSpace emptyScope "shared-source"
    assertBool "two spaces derived one scene timer id" (mineTimer /= theirsTimer)
    rows <- runOrFail env do
      scheduleTestTimer
        mineTimer
        l2SceneProcessManagerName
        (partitionedCorrelationId testSpace emptyScope)
        (sceneTimerPayload testSpace)
        (-1)
      scheduleTestTimer
        theirsTimer
        l2SceneProcessManagerName
        (partitionedCorrelationId otherSpace emptyScope)
        (sceneTimerPayload otherSpace)
        (-1)
      void (drainKiokuTimers Nothing testContextProvider rt (scopedScanCandidates 5))
      traverse fetchTimer [mineTimer, theirsTimer]
    fmap (.status) rows @?= ["fired", "fired"]

-- | A worker that may not act in a space must say so where an operator can see it.
--
-- Dead-letter rather than retry, for the same reason the embedding worker does: a refusal is a
-- configuration fact, and retrying it every thirty seconds for an hour before giving up would
-- spend an hour hiding it.
testRefusedSpaceDeadLetters :: Assertion
testRefusedSpaceDeadLetters =
  withTimerEnv \env rt -> do
    timerId <- freshTimerId
    row <- runOrFail env do
      scheduleTestTimer
        timerId
        l2SceneProcessManagerName
        (partitionedCorrelationId testSpace emptyScope)
        (sceneTimerPayload testSpace)
        (-1)
      fireOnceWith refusingContextProvider rt
      fetchTimer timerId
    row.status @?= "dead"
    assertBool
      ("last_error should name the refusal, got: " <> show row.lastError)
      (maybe False (Text.isInfixOf "not authorized") row.lastError)

-- | @last_error@ is the column an operator reads when a distillation stops happening, and a
-- dead-lettered timer that does not say which tenant it belongs to is a question, not an answer.
testDeadLetterNamesTheSpace :: Assertion
testDeadLetterNamesTheSpace =
  withTimerEnv \env rt -> do
    timerId <- freshTimerId
    sid <- genSessionId
    row <- runOrFail env do
      startFixtureSession sid
      -- A correlation id that is not a session id: a permanent failure whose own message has no
      -- reason to mention a space, so what shows up can only have come from the annotation.
      scheduleTestTimer timerId l1ExtractProcessManagerName "not-a-session-id" (l1Payload testSpace) (-1)
      fireOnce rt
      fetchTimer timerId
    row.status @?= "dead"
    assertBool
      ("last_error should name the memory space, got: " <> show row.lastError)
      (maybe False (Text.isInfixOf (memorySpaceIdText testSpace)) row.lastError)

-- | An L2 scene timer payload as the projection writes one.
sceneTimerPayload :: MemorySpaceId -> Aeson.Value
sceneTimerPayload space =
  Aeson.toJSON SceneTimerPayload {memorySpaceId = space, scope = emptyScope}

-- | A provider that refuses every space, as a host with a real authorization engine would when
-- this worker is not allowed to touch this tenant.
refusingContextProvider :: (Applicative m) => MemoryContextProvider m
refusingContextProvider =
  MemoryContextProvider \space -> pure (Left (MemoryPermissionDenied space MemoryDistill))

fireOnce ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es, Tracing :> es) =>
  DistillRuntime ->
  Eff es ()
fireOnce = fireOnceWith testContextProvider

fireOnceWith ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es, Tracing :> es) =>
  MemoryContextProvider (Eff es) ->
  DistillRuntime ->
  Eff es ()
fireOnceWith provider rt = do
  now <- liftIO getCurrentTime
  void (runKiokuTimerWorkerOnce Nothing provider rt (scopedScanCandidates 5) now)

-- | Schedule a timer @offset@ seconds from now (negative means already due).
scheduleTestTimer ::
  (IOE :> es, Store :> es) =>
  TimerId ->
  Text ->
  Text ->
  Aeson.Value ->
  NominalDiffTime ->
  Eff es ()
scheduleTestTimer timerId processManagerName correlationId payload offset = do
  now <- liftIO getCurrentTime
  runTransaction $
    scheduleTimerTx
      TimerRequest
        { timerId,
          processManagerName,
          correlationId,
          fireAt = addUTCTime offset now,
          payload
        }

startFixtureSession ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
  SessionId ->
  Eff es ()
startFixtureSession = startFixtureSessionIn testContext testSpace

-- | Start a fixture session in a named space. The pre-partition case needs the legacy one:
-- in a genuinely pre-partition database the timer and its session are both there, and a
-- fixture that put them in different spaces would be testing nothing that can happen.
startFixtureSessionIn ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
  MemoryAccessContext ->
  MemorySpaceId ->
  SessionId ->
  Eff es ()
startFixtureSessionIn context space sid = do
  now <- liftIO getCurrentTime
  started <-
    Session.startWithContext
      context
      StartSessionData
        { sessionId = sid,
          memorySpaceId = space,
          actorPrincipal = memoryContextRecordedActor context,
          ownerPrincipal = Nothing,
          agentId = "test-agent",
          focus = "timer worker spec",
          scope = emptyScope,
          subjectRef = Nothing,
          previousSessionId = Nothing,
          parentSessionId = Nothing,
          delegationDepth = 0,
          startedAt = now
        }
  void (liftIO (expectRight "Session.startWithContext" started))

-- | Drive the row to the brink of the ceiling so the next claim trips it.
forceAttempts :: (Store :> es) => TimerId -> Int -> Eff es ()
forceAttempts (TimerId uuid) attempts =
  runTransaction (Tx.statement (uuid, fromIntegral @Int @Int64 attempts) forceAttemptsStmt)

forceAttemptsStmt :: Statement (UUID.UUID, Int64) ()
forceAttemptsStmt =
  preparable
    """
    UPDATE keiro.keiro_timers
    SET attempts = $2,
        fire_at = now() - interval '1 second'
    WHERE timer_id = $1
    """
    ( ((\(u, _) -> u) >$< E.param (E.nonNullable E.uuid))
        <> ((\(_, a) -> a) >$< E.param (E.nonNullable E.int8))
    )
    D.noResult

data TimerStateRow = TimerStateRow
  { status :: !Text,
    attempts :: !Int,
    fireAt :: !UTCTime,
    lastError :: !(Maybe Text),
    firedEventId :: !(Maybe Text)
  }
  deriving stock (Generic, Eq, Show)

fetchTimer :: (Store :> es) => TimerId -> Eff es TimerStateRow
fetchTimer (TimerId uuid) =
  runTransaction (Tx.statement uuid selectTimerStateStmt)

selectTimerStateStmt :: Statement UUID.UUID TimerStateRow
selectTimerStateStmt =
  preparable
    """
    SELECT status, attempts, fire_at, last_error, fired_event_id::text
    FROM keiro.keiro_timers
    WHERE timer_id = $1
    """
    (E.param (E.nonNullable E.uuid))
    (D.singleRow timerStateDecoder)

timerStateDecoder :: D.Row TimerStateRow
timerStateDecoder =
  TimerStateRow
    <$> D.column (D.nonNullable D.text)
    <*> (fromIntegral @Int64 @Int <$> D.column (D.nonNullable D.int8))
    <*> D.column (D.nonNullable D.timestamptz)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.text)

-- | The delay is measured against a clock read taken before the pass, so the
-- window has to absorb the pass's own duration. A generous window still
-- distinguishes 30s from 600s, which is the distinction under test.
assertDelayNear :: String -> UTCTime -> NominalDiffTime -> UTCTime -> Assertion
assertDelayNear label before expected actual =
  assertBool
    ( label
        <> ": expected fire_at about "
        <> show expected
        <> "s out, but it was "
        <> show delay
        <> "s out"
    )
    (delay >= expected - 5 && delay <= expected + 15)
  where
    delay = actual `diffUTCTime` before

emptyScope :: MemoryScope
emptyScope =
  ScopeEntity (Namespace "rei") (ScopeKind "intention") "intention_timer_worker_spec"

freshTimerId :: IO TimerId
freshTimerId = TimerId <$> UUIDv4.nextRandom

withTimerEnv :: (AppEnv -> DistillRuntime -> IO ()) -> Assertion
withTimerEnv action =
  withKiokuMigratedDatabase \connStr ->
    withNoopAppEnv (defaultConnectionSettings connStr) \env -> do
      rt <- newDistillRuntime
      action env rt

runOrFail :: AppEnv -> Eff AppEffects a -> IO a
runOrFail env action = runAppIO env action >>= expectRight "runAppIO"

expectRight :: (Show e) => String -> Either e a -> IO a
expectRight label = \case
  Left err -> assertFailure (label <> " failed: " <> show err)
  Right value -> pure value