packages feed

keiro 0.15.0.0 → 0.16.0.0

raw patch · 5 files changed

+839/−11 lines, 5 filesdep ~keiro-coredep ~keiro-test-supportPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: keiro-core, keiro-test-support

API changes (from Hackage documentation)

+ Keiro.Timer: AnyTimerReason :: TimerReasonFilter
+ Keiro.Timer: DeadTimerClaimRequest :: !TimerId -> !Text -> !Text -> !Int -> !Int -> DeadTimerClaimRequest
+ Keiro.Timer: DeadTimerFilter :: !Maybe Text -> !TimerReasonFilter -> DeadTimerFilter
+ Keiro.Timer: DeadTimerPage :: ![TimerInspection] -> !Maybe TimerId -> DeadTimerPage
+ Keiro.Timer: DeadTimerPageRequest :: !Int -> !Maybe TimerId -> DeadTimerPageRequest
+ Keiro.Timer: InvalidDeadTimerPageSize :: !Int -> DeadTimerReadError
+ Keiro.Timer: InvalidTimerResumeLeaseSeconds :: !Int -> TimerResumeError
+ Keiro.Timer: InvalidTimerResumeMaxAttempts :: !Int -> TimerResumeError
+ Keiro.Timer: ReasonAbsent :: TimerReasonFilter
+ Keiro.Timer: ReasonExact :: !Text -> TimerReasonFilter
+ Keiro.Timer: ReasonPrefix :: !Text -> TimerReasonFilter
+ Keiro.Timer: TimerInspection :: !TimerRow -> !Maybe Text -> TimerInspection
+ Keiro.Timer: [afterTimerId] :: DeadTimerPageRequest -> !Maybe TimerId
+ Keiro.Timer: [expectedReason] :: DeadTimerClaimRequest -> !Text
+ Keiro.Timer: [lastError] :: TimerInspection -> !Maybe Text
+ Keiro.Timer: [leaseSeconds] :: DeadTimerClaimRequest -> !Int
+ Keiro.Timer: [nextAfterTimerId] :: DeadTimerPage -> !Maybe TimerId
+ Keiro.Timer: [pageSize] :: DeadTimerPageRequest -> !Int
+ Keiro.Timer: [reason] :: DeadTimerFilter -> !TimerReasonFilter
+ Keiro.Timer: [timer] :: TimerInspection -> !TimerRow
+ Keiro.Timer: [timers] :: DeadTimerPage -> ![TimerInspection]
+ Keiro.Timer: anyDeadTimer :: DeadTimerFilter
+ Keiro.Timer: cancelTimerResume :: forall (es :: [Effect]). Store :> es => TimerResumeClaim -> Eff es Bool
+ Keiro.Timer: claimDeadTimer :: forall (es :: [Effect]). (IOE :> es, Store :> es) => DeadTimerClaimRequest -> Eff es (Either TimerResumeError (Maybe TimerResumeClaim))
+ Keiro.Timer: completeTimerResume :: forall (es :: [Effect]). Store :> es => TimerResumeClaim -> EventId -> Eff es Bool
+ Keiro.Timer: data DeadTimerClaimRequest
+ Keiro.Timer: data DeadTimerFilter
+ Keiro.Timer: data DeadTimerPage
+ Keiro.Timer: data DeadTimerPageRequest
+ Keiro.Timer: data DeadTimerReadError
+ Keiro.Timer: data TimerInspection
+ Keiro.Timer: data TimerReasonFilter
+ Keiro.Timer: data TimerResumeClaim
+ Keiro.Timer: data TimerResumeError
+ Keiro.Timer: findDeadTimers :: forall (es :: [Effect]). Store :> es => DeadTimerFilter -> DeadTimerPageRequest -> Eff es (Either DeadTimerReadError DeadTimerPage)
+ Keiro.Timer: lookupTimerInspection :: forall (es :: [Effect]). Store :> es => TimerId -> Eff es (Maybe TimerInspection)
+ Keiro.Timer: parkTimerResume :: forall (es :: [Effect]). Store :> es => TimerResumeClaim -> Eff es Bool
+ Keiro.Timer: recoverExpiredTimerResumes :: forall (es :: [Effect]). Store :> es => Eff es Int
+ Keiro.Timer: renewTimerResume :: forall (es :: [Effect]). Store :> es => TimerResumeClaim -> Int -> Eff es (Either TimerResumeError Bool)
+ Keiro.Timer: resumeClaimLeaseUntil :: TimerResumeClaim -> UTCTime
+ Keiro.Timer: resumeClaimTimer :: TimerResumeClaim -> TimerRow
+ Keiro.Timer.Schema: AnyTimerReason :: TimerReasonFilter
+ Keiro.Timer.Schema: DeadTimerClaimRequest :: !TimerId -> !Text -> !Text -> !Int -> !Int -> DeadTimerClaimRequest
+ Keiro.Timer.Schema: DeadTimerFilter :: !Maybe Text -> !TimerReasonFilter -> DeadTimerFilter
+ Keiro.Timer.Schema: DeadTimerPage :: ![TimerInspection] -> !Maybe TimerId -> DeadTimerPage
+ Keiro.Timer.Schema: DeadTimerPageRequest :: !Int -> !Maybe TimerId -> DeadTimerPageRequest
+ Keiro.Timer.Schema: InvalidDeadTimerPageSize :: !Int -> DeadTimerReadError
+ Keiro.Timer.Schema: InvalidTimerResumeLeaseSeconds :: !Int -> TimerResumeError
+ Keiro.Timer.Schema: InvalidTimerResumeMaxAttempts :: !Int -> TimerResumeError
+ Keiro.Timer.Schema: ReasonAbsent :: TimerReasonFilter
+ Keiro.Timer.Schema: ReasonExact :: !Text -> TimerReasonFilter
+ Keiro.Timer.Schema: ReasonPrefix :: !Text -> TimerReasonFilter
+ Keiro.Timer.Schema: TimerInspection :: !TimerRow -> !Maybe Text -> TimerInspection
+ Keiro.Timer.Schema: [afterTimerId] :: DeadTimerPageRequest -> !Maybe TimerId
+ Keiro.Timer.Schema: [expectedReason] :: DeadTimerClaimRequest -> !Text
+ Keiro.Timer.Schema: [lastError] :: TimerInspection -> !Maybe Text
+ Keiro.Timer.Schema: [leaseSeconds] :: DeadTimerClaimRequest -> !Int
+ Keiro.Timer.Schema: [maxAttempts] :: DeadTimerClaimRequest -> !Int
+ Keiro.Timer.Schema: [nextAfterTimerId] :: DeadTimerPage -> !Maybe TimerId
+ Keiro.Timer.Schema: [pageSize] :: DeadTimerPageRequest -> !Int
+ Keiro.Timer.Schema: [reason] :: DeadTimerFilter -> !TimerReasonFilter
+ Keiro.Timer.Schema: [timer] :: TimerInspection -> !TimerRow
+ Keiro.Timer.Schema: [timers] :: DeadTimerPage -> ![TimerInspection]
+ Keiro.Timer.Schema: anyDeadTimer :: DeadTimerFilter
+ Keiro.Timer.Schema: cancelTimerResume :: forall (es :: [Effect]). Store :> es => TimerResumeClaim -> Eff es Bool
+ Keiro.Timer.Schema: claimDeadTimer :: forall (es :: [Effect]). (IOE :> es, Store :> es) => DeadTimerClaimRequest -> Eff es (Either TimerResumeError (Maybe TimerResumeClaim))
+ Keiro.Timer.Schema: completeTimerResume :: forall (es :: [Effect]). Store :> es => TimerResumeClaim -> EventId -> Eff es Bool
+ Keiro.Timer.Schema: data DeadTimerClaimRequest
+ Keiro.Timer.Schema: data DeadTimerFilter
+ Keiro.Timer.Schema: data DeadTimerPage
+ Keiro.Timer.Schema: data DeadTimerPageRequest
+ Keiro.Timer.Schema: data DeadTimerReadError
+ Keiro.Timer.Schema: data TimerInspection
+ Keiro.Timer.Schema: data TimerReasonFilter
+ Keiro.Timer.Schema: data TimerResumeClaim
+ Keiro.Timer.Schema: data TimerResumeError
+ Keiro.Timer.Schema: findDeadTimers :: forall (es :: [Effect]). Store :> es => DeadTimerFilter -> DeadTimerPageRequest -> Eff es (Either DeadTimerReadError DeadTimerPage)
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.DeadTimerClaimRequest
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.DeadTimerFilter
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.DeadTimerPage
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.DeadTimerPageRequest
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.DeadTimerReadError
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.TimerInspection
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.TimerReasonFilter
+ Keiro.Timer.Schema: instance GHC.Classes.Eq Keiro.Timer.Schema.TimerResumeError
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.DeadTimerClaimRequest
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.DeadTimerFilter
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.DeadTimerPage
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.DeadTimerPageRequest
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.DeadTimerReadError
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.TimerInspection
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.TimerReasonFilter
+ Keiro.Timer.Schema: instance GHC.Internal.Generics.Generic Keiro.Timer.Schema.TimerResumeError
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.DeadTimerClaimRequest
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.DeadTimerFilter
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.DeadTimerPage
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.DeadTimerPageRequest
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.DeadTimerReadError
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.TimerInspection
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.TimerReasonFilter
+ Keiro.Timer.Schema: instance GHC.Internal.Show.Show Keiro.Timer.Schema.TimerResumeError
+ Keiro.Timer.Schema: lookupTimerInspection :: forall (es :: [Effect]). Store :> es => TimerId -> Eff es (Maybe TimerInspection)
+ Keiro.Timer.Schema: parkTimerResume :: forall (es :: [Effect]). Store :> es => TimerResumeClaim -> Eff es Bool
+ Keiro.Timer.Schema: recoverExpiredTimerResumes :: forall (es :: [Effect]). Store :> es => Eff es Int
+ Keiro.Timer.Schema: renewTimerResume :: forall (es :: [Effect]). Store :> es => TimerResumeClaim -> Int -> Eff es (Either TimerResumeError Bool)
+ Keiro.Timer.Schema: resumeClaimLeaseUntil :: TimerResumeClaim -> UTCTime
+ Keiro.Timer.Schema: resumeClaimTimer :: TimerResumeClaim -> TimerRow
- Keiro.Timer: [processManagerName] :: TimerRow -> !Text
+ Keiro.Timer: [processManagerName] :: DeadTimerClaimRequest -> !Text
- Keiro.Timer: [timerId] :: TimerRow -> !TimerId
+ Keiro.Timer: [timerId] :: DeadTimerClaimRequest -> !TimerId
- Keiro.Timer.Schema: [processManagerName] :: TimerRow -> !Text
+ Keiro.Timer.Schema: [processManagerName] :: DeadTimerClaimRequest -> !Text
- Keiro.Timer.Schema: [timerId] :: TimerRow -> !TimerId
+ Keiro.Timer.Schema: [timerId] :: DeadTimerClaimRequest -> !TimerId

Files

CHANGELOG.md view
@@ -4,7 +4,30 @@ [Keep a Changelog](https://keepachangelog.com/), and the project aims to follow the [Haskell Package Versioning Policy](https://pvp.haskell.org/). -## Unreleased+## [Unreleased]++## 0.16.0.0 — 2026-09-07++### Breaking Changes++- Ordinary worker passes recover expired foreground claims even when ordinary+  requeueing is disabled. ID-only timer mutations refuse guarded claims.+  Existing rows and callback signatures remain source-compatible.+- Stop/drain all old timer writers before migration 0032 and deploy upgraded+  writers before enabling resume. Mixed-version writers are unsafe. Disable+  resume and drain/recover claims before rollback. External effects remain+  at-least-once and require consumer-owned idempotency.++### New Features++- Add guarded Dead timer claims with exact owner/reason checks, total attempt+  ceilings, and opaque expiring ownership. Complete, renew, park, cancel, or+  recover directly to Dead while retaining original work and retry history.++- Add `Keiro.Timer.lookupTimerInspection` with full nullable stored reasons and+  `findDeadTimers` with exact owner/literal reason filters, 1–100 row bounds,+  and exclusive UUID pagination. Existing timer rows and worker APIs remain+  compatible; callers own payload authorization before rendering.  ## 0.15.0.0 — 2026-08-30 
keiro.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            keiro-version:         0.15.0.0+version:         0.16.0.0 synopsis:        Event sourcing framework and workflow engine description:   A library that composes kiroku, keiki, and shibuya into an@@ -147,7 +147,7 @@     , hs-opentelemetry-semantic-conventions  >=1.40      && <2     , keiki                                  >=0.9       && <0.10     , keiki-codec-json                       >=0.9       && <0.10-    , keiro-core                             ^>=0.15.0.0+    , keiro-core                             ^>=0.16.0.0     , kiroku-store                           >=0.8       && <0.9     , lens                                   >=5.2       && <5.4     , mmzk-typeid                            >=0.7       && <0.8@@ -205,7 +205,7 @@     , keiki     , keiki-codec-json     , keiro-    , keiro-test-support                     ^>=0.15.0.0+    , keiro-test-support                     ^>=0.16.0.0     , kiroku-store                           >=0.8       && <0.9     , process                                >=1.6       && <1.7     , shibuya-core                           ^>=0.9.0.0@@ -236,8 +236,8 @@     , hs-opentelemetry-sdk  >=1.0       && <1.1     , keiki                 >=0.9       && <0.10     , keiro-    , keiro-core            ^>=0.15.0.0-    , keiro-test-support    ^>=0.15.0.0+    , keiro-core            ^>=0.16.0.0+    , keiro-test-support    ^>=0.16.0.0     , kiroku-store          >=0.8       && <0.9     , shibuya-core          ^>=0.9.0.0     , streamly-core         >=0.3       && <0.4
src/Keiro/Timer.hs view
@@ -9,6 +9,14 @@ -- timer left @Firing@ by a crash becomes claimable again after the worker's -- configured stale-claim timeout, giving at-least-once firing. --+-- Guarded foreground resumes use 'claimDeadTimer' after consumer authorization+-- and session preflight. Renew during work, then complete, park, or cancel with+-- the opaque handle. Expired claims recover directly to Dead, retaining reason+-- and attempts; even workers with ordinary recovery disabled perform this sweep.+-- ID-only mutations refuse guarded claims. External work remains at-least-once;+-- callers own cancellation and durable result deduplication. Upgrade every timer+-- writer before enabling resume; old binaries do not enforce the token guards.+-- -- The wire types live in "Keiro.Timer.Types" and the SQL storage in -- "Keiro.Timer.Schema"; both are re-exported here so most callers need only -- import @Keiro.Timer@.@@ -19,6 +27,30 @@     TimerRow (..),     TimerStatus (..), +    -- * Read-only inspection+    TimerInspection (..),+    TimerReasonFilter (..),+    DeadTimerFilter (..),+    anyDeadTimer,+    DeadTimerPageRequest (..),+    DeadTimerReadError (..),+    DeadTimerPage (..),+    lookupTimerInspection,+    findDeadTimers,++    -- * Guarded foreground resume+    DeadTimerClaimRequest (..),+    TimerResumeError (..),+    TimerResumeClaim,+    resumeClaimTimer,+    resumeClaimLeaseUntil,+    claimDeadTimer,+    renewTimerResume,+    completeTimerResume,+    parkTimerResume,+    cancelTimerResume,+    recoverExpiredTimerResumes,+     -- * Storage     scheduleTimerTx,     scheduleTimerOnceTx,@@ -76,7 +108,7 @@     --     @updated_at@ is at least @ttl@ old back to 'Scheduled'. A fire action that     --     runs longer than this timeout may be fired again; timer handlers must be     --     idempotent under keiro's at-least-once timer contract. @Nothing@ disables-    --     automatic requeue for callers that run their own recovery.+    --     automatic ordinary requeue. Expired foreground recovery always runs.     requeueStuckAfter :: !(Maybe NominalDiffTime)   }   deriving stock (Generic, Eq, Show)@@ -142,6 +174,7 @@   UTCTime ->   Eff es () timerPassPreamble metrics options now = do+  void recoverExpiredTimerResumes   for_ (options ^. #requeueStuckAfter) $ \ttl -> do     requeued <- requeueStuckTimers ttl now     recordTimerRequeued metrics (fromIntegral requeued)
src/Keiro/Timer/Schema.hs view
@@ -8,6 +8,9 @@ -- records completion and the produced event id. Stale @Firing@ rows are requeued -- by 'requeueStuckTimers' so a crashed worker does not strand a timer forever. --+-- ID-only completion, cancellation, dead-lettering and requeueing refuse all+-- token-bearing foreground claims, including expired claims awaiting recovery.+-- -- Callers normally use the re-exports from "Keiro.Timer" rather than this -- module directly. module Keiro.Timer.Schema@@ -15,6 +18,30 @@     TimerStatus (..),     TimerRow (..), +    -- * Read-only inspection+    TimerInspection (..),+    TimerReasonFilter (..),+    DeadTimerFilter (..),+    anyDeadTimer,+    DeadTimerPageRequest (..),+    DeadTimerReadError (..),+    DeadTimerPage (..),+    lookupTimerInspection,+    findDeadTimers,++    -- * Guarded foreground resume+    DeadTimerClaimRequest (..),+    TimerResumeError (..),+    TimerResumeClaim,+    resumeClaimTimer,+    resumeClaimLeaseUntil,+    claimDeadTimer,+    renewTimerResume,+    completeTimerResume,+    parkTimerResume,+    cancelTimerResume,+    recoverExpiredTimerResumes,+     -- * Storage     scheduleTimerTx,     scheduleTimerOnceTx,@@ -37,10 +64,12 @@   ) where -import Contravariant.Extras (contrazip2, contrazip6)+import Contravariant.Extras (contrazip2, contrazip5, contrazip6)+import Data.Int (Int32) import Data.Time (NominalDiffTime, addUTCTime) import Data.UUID (UUID)-import Effectful (Eff, (:>))+import Data.UUID.V4 qualified as UUIDv4+import Effectful (Eff, IOE, (:>)) import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable)@@ -58,7 +87,7 @@ --   claimable again when 'requeueStuckTimers' moves them back to 'Scheduled'. -- * 'Fired' — successfully fired; terminal. -- * 'Cancelled' — withdrawn before firing.--- * 'Dead' — abandoned after exceeding the attempt ceiling; terminal; carries an+-- * 'Dead' — parked or abandoned; guarded foreground resume is possible; carries an --   optional @last_error@ describing why it was given up on. data TimerStatus   = Scheduled@@ -85,6 +114,280 @@   }   deriving stock (Generic, Eq, Show) +-- | Original timer metadata and the full stored reason. NULL and empty text+-- remain distinct. Reading does not claim work or authorize its disclosure.+data TimerInspection = TimerInspection+  { timer :: !TimerRow,+    lastError :: !(Maybe Text)+  }+  deriving stock (Generic, Eq, Show)++-- | Case-sensitive literal reason matching. An empty prefix matches every+-- non-NULL reason; percent, underscore, and backslash are ordinary characters.+data TimerReasonFilter+  = AnyTimerReason+  | ReasonAbsent+  | ReasonExact !Text+  | ReasonPrefix !Text+  deriving stock (Generic, Eq, Show)++-- | Dead rows matching both the optional exact owner and the reason predicate.+-- The owner label is not an application authorization credential.+data DeadTimerFilter = DeadTimerFilter+  { processManagerName :: !(Maybe Text),+    reason :: !TimerReasonFilter+  }+  deriving stock (Generic, Eq, Show)++-- | Select all dead timers.+anyDeadTimer :: DeadTimerFilter+anyDeadTimer = DeadTimerFilter Nothing AnyTimerReason++-- | Request 1 through 100 rows, strictly after an optional UUID cursor.+-- Restart without a cursor when changing filters.+data DeadTimerPageRequest = DeadTimerPageRequest+  { pageSize :: !Int,+    afterTimerId :: !(Maybe TimerId)+  }+  deriving stock (Generic, Eq, Show)++-- | Invalid sizes are rejected before database access, without clamping.+data DeadTimerReadError = InvalidDeadTimerPageSize !Int+  deriving stock (Generic, Eq, Show)++-- | Ascending UUID order, not chronological order. Continuation exists only+-- when another matching row was observed. Requests see current eligibility,+-- not a shared snapshot: newly eligible IDs behind the cursor are not revisited.+data DeadTimerPage = DeadTimerPage+  { timers :: ![TimerInspection],+    nextAfterTimerId :: !(Maybe TimerId)+  }+  deriving stock (Generic, Eq, Show)++-- | Inspect any lifecycle state without mutations or row-claim locks.+lookupTimerInspection :: (Store :> es) => TimerId -> Eff es (Maybe TimerInspection)+lookupTimerInspection timerId =+  runTransaction $ Tx.statement (timerIdToUuid timerId) lookupTimerInspectionStmt++-- | Observe a bounded page of dead timers. Limits bound returned rows, not+-- database search cost. Callers must decode and authorize before rendering,+-- following storage continuation even if authorization removes an entire page.+findDeadTimers ::+  (Store :> es) =>+  DeadTimerFilter ->+  DeadTimerPageRequest ->+  Eff es (Either DeadTimerReadError DeadTimerPage)+findDeadTimers deadFilter request+  | size < 1 || size > 100 = pure (Left (InvalidDeadTimerPageSize size))+  | otherwise = do+      rows <-+        runTransaction $+          Tx.statement+            ( deadFilter ^. #processManagerName,+              mode,+              reasonText,+              timerIdToUuid <$> request ^. #afterTimerId,+              fromIntegral size + 1+            )+            findDeadTimersStmt+      let selected = take size rows+          continuation = case drop size rows of+            [] -> Nothing+            _ -> case reverse selected of+              lastRow : _ -> Just (lastRow ^. #timer . #timerId)+              [] -> Nothing+      pure (Right (DeadTimerPage selected continuation))+  where+    size = request ^. #pageSize+    (mode, reasonText) = case deadFilter ^. #reason of+      AnyTimerReason -> (0, Nothing)+      ReasonAbsent -> (1, Nothing)+      ReasonExact value -> (2, Just value)+      ReasonPrefix value -> (3, Just value)++-- | Exact dead-row guards and an explicit total attempt ceiling. Lease seconds+-- must be between 1 and 2147483647, avoiding interval conversion overflow.+data DeadTimerClaimRequest = DeadTimerClaimRequest+  { timerId :: !TimerId,+    processManagerName :: !Text,+    expectedReason :: !Text,+    maxAttempts :: !Int,+    leaseSeconds :: !Int+  }+  deriving stock (Generic, Eq, Show)++data TimerResumeError+  = InvalidTimerResumeMaxAttempts !Int+  | InvalidTimerResumeLeaseSeconds !Int+  deriving stock (Generic, Eq, Show)++-- | Opaque storage ownership, not application authorization.+data TimerResumeClaim = TimerResumeClaim !TimerRow !UUID !UTCTime++-- | Original work as claimed, including the incremented attempt count.+resumeClaimTimer :: TimerResumeClaim -> TimerRow+resumeClaimTimer (TimerResumeClaim row _ _) = row++-- | Claim-time snapshot only. Renewals retain the token and update the database;+-- schedule renewals by the requested interval, not this old snapshot.+resumeClaimLeaseUntil :: TimerResumeClaim -> UTCTime+resumeClaimLeaseUntil (TimerResumeClaim _ _ deadline) = deadline++validResumeLease :: Int -> Bool+validResumeLease seconds = seconds > 0 && toInteger seconds <= toInteger (maxBound :: Int32)++-- | Claim only the exact owner and non-NULL reason. Refusal consumes no attempt.+-- Authorize and establish session availability before calling; execute outside+-- the retried SQL transaction, only after receiving ownership.+claimDeadTimer :: (IOE :> es, Store :> es) => DeadTimerClaimRequest -> Eff es (Either TimerResumeError (Maybe TimerResumeClaim))+claimDeadTimer request+  | request ^. #maxAttempts < 0 = pure (Left (InvalidTimerResumeMaxAttempts (request ^. #maxAttempts)))+  | not (validResumeLease (request ^. #leaseSeconds)) = pure (Left (InvalidTimerResumeLeaseSeconds (request ^. #leaseSeconds)))+  | otherwise = do+      token <- liftIO UUIDv4.nextRandom+      Right+        <$> runTransaction+          ( do+              locked <- lockTimerResumeTx (request ^. #timerId)+              if not locked+                then pure Nothing+                else+                  Tx.statement+                    ( timerIdToUuid (request ^. #timerId),+                      request ^. #processManagerName,+                      request ^. #expectedReason,+                      fromIntegral (request ^. #maxAttempts),+                      token,+                      fromIntegral (request ^. #leaseSeconds)+                    )+                    claimDeadTimerStmt+          )++-- Lock in a separate statement: subsequent predicates and clock_timestamp()+-- observe the committed winner after a ReadCommitted lock wait. A missing row+-- must return immediately: a later insert must not bypass the initial lock.+lockTimerResumeTx :: TimerId -> Tx.Transaction Bool+lockTimerResumeTx tid = isJust <$> Tx.statement (timerIdToUuid tid) lockTimerResumeStmt++lockTimerResumeStmt :: Statement UUID (Maybe UUID)+lockTimerResumeStmt =+  preparable+    "SELECT timer_id FROM keiro.keiro_timers WHERE timer_id = $1 FOR UPDATE"+    (E.param (E.nonNullable E.uuid))+    (D.rowMaybe (D.column (D.nonNullable D.uuid)))++claimDeadTimerStmt :: Statement (UUID, Text, Text, Int64, UUID, Int32) (Maybe TimerResumeClaim)+claimDeadTimerStmt =+  preparable+    """+    WITH stamp AS MATERIALIZED (SELECT clock_timestamp() AS now)+    UPDATE keiro.keiro_timers kt+    SET status = 'firing', attempts = attempts + 1,+        resume_claim_token = $5, resume_lease_until = stamp.now + $6::integer * interval '1 second',+        updated_at = stamp.now+    FROM stamp+    WHERE timer_id = $1 AND status = 'dead'+      AND process_manager_name COLLATE "C" = $2+      AND last_error COLLATE "C" = $3 AND attempts < $4+    RETURNING kt.timer_id, kt.process_manager_name, kt.correlation_id, kt.fire_at,+      kt.payload, kt.status, kt.attempts, kt.fired_event_id, kt.resume_claim_token, kt.resume_lease_until+    """+    ( contrazip6+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowMaybe (TimerResumeClaim <$> timerRowDecoder <*> D.column (D.nonNullable D.uuid) <*> D.column (D.nonNullable D.timestamptz)))++-- | Extend from database time. False means ownership was lost; an expired claim+-- cannot be revived. Stop local work where possible on loss of ownership.+renewTimerResume :: (Store :> es) => TimerResumeClaim -> Int -> Eff es (Either TimerResumeError Bool)+renewTimerResume claim seconds+  | not (validResumeLease seconds) = pure (Left (InvalidTimerResumeLeaseSeconds seconds))+  | otherwise = Right <$> mutateTimerResume claim "firing" Nothing (Just (fromIntegral seconds))++-- | Complete with the resulting event. False must not be reported as successful+-- timer completion. External effects still need caller-owned idempotency.+completeTimerResume :: (Store :> es) => TimerResumeClaim -> EventId -> Eff es Bool+completeTimerResume claim event = mutateTimerResume claim "fired" (Just (eventIdToUuid event)) Nothing++-- | Return to Dead with the original reason and incremented attempts retained.+parkTimerResume :: (Store :> es) => TimerResumeClaim -> Eff es Bool+parkTimerResume claim = mutateTimerResume claim "dead" Nothing Nothing++-- | Explicit abandonment by the current owner.+cancelTimerResume :: (Store :> es) => TimerResumeClaim -> Eff es Bool+cancelTimerResume claim = mutateTimerResume claim "cancelled" Nothing Nothing++mutateTimerResume :: (Store :> es) => TimerResumeClaim -> Text -> Maybe UUID -> Maybe Int32 -> Eff es Bool+mutateTimerResume (TimerResumeClaim row token _) target event seconds = runTransaction $ do+  locked <- lockTimerResumeTx (row ^. #timerId)+  if not locked+    then pure False+    else+      Tx.statement (timerIdToUuid (row ^. #timerId), token, target, event, seconds) mutateTimerResumeStmt++mutateTimerResumeStmt :: Statement (UUID, UUID, Text, Maybe UUID, Maybe Int32) Bool+mutateTimerResumeStmt =+  preparable+    """+    WITH stamp AS MATERIALIZED (SELECT clock_timestamp() AS now)+    UPDATE keiro.keiro_timers+    SET status = $3,+        fired_event_id = CASE WHEN $3 = 'fired' THEN $4 ELSE fired_event_id END,+        resume_claim_token = CASE WHEN $5::integer IS NULL THEN NULL ELSE resume_claim_token END,+        resume_lease_until = CASE WHEN $5::integer IS NULL THEN NULL ELSE stamp.now + $5 * interval '1 second' END,+        updated_at = stamp.now+    FROM stamp+    WHERE timer_id = $1 AND resume_claim_token = $2 AND status = 'firing'+      AND resume_lease_until > stamp.now+    """+    ( contrazip5+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.uuid))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.uuid))+        (E.param (E.nullable E.int4))+    )+    ((> 0) <$> D.rowsAffected)++-- | Re-park expired foreground work directly to Dead. Foreground-only hosts+-- must run this periodically and before discovery/resume. Ordinary worker passes+-- also run it, independently of their ordinary stale-claim recovery option.+recoverExpiredTimerResumes :: (Store :> es) => Eff es Int+recoverExpiredTimerResumes = runTransaction $ do+  -- Deterministic lock ordering, with a fresh predicate in the second statement.+  ids <- Tx.statement () lockExpiredTimerResumesStmt+  sum <$> traverse (\tid -> Tx.statement tid recoverExpiredTimerResumesStmt) ids++lockExpiredTimerResumesStmt :: Statement () [UUID]+lockExpiredTimerResumesStmt =+  preparable+    """+    SELECT timer_id FROM keiro.keiro_timers+    WHERE status = 'firing' AND resume_claim_token IS NOT NULL+      AND resume_lease_until <= clock_timestamp()+    ORDER BY timer_id FOR UPDATE+    """+    mempty+    (D.rowList (D.column (D.nonNullable D.uuid)))++recoverExpiredTimerResumesStmt :: Statement UUID Int+recoverExpiredTimerResumesStmt =+  preparable+    """+    UPDATE keiro.keiro_timers+    SET status = 'dead', resume_claim_token = NULL, resume_lease_until = NULL,+        updated_at = clock_timestamp()+    WHERE timer_id = $1 AND status = 'firing' AND resume_claim_token IS NOT NULL+      AND resume_lease_until <= clock_timestamp()+    """+    (E.param (E.nonNullable E.uuid))+    (fromIntegral <$> D.rowsAffected)+ -- | Criteria selecting timers stranded in 'Firing'. A row is "stuck" when its -- 'status' is @firing@ and it matches every set bound: 'minAge' (it has been -- firing at least this long, measured from @updated_at@) and 'minAttempts' (it@@ -221,7 +524,7 @@  -- | Move a timer from @Scheduled@ or @Firing@ to the terminal @Dead@ state, -- recording @reason@ in @last_error@ so an operator can see why it was abandoned--- (@SELECT * FROM keiro_timers WHERE status = 'dead'@). Terminal rows are left+-- through 'lookupTimerInspection' or 'findDeadTimers'. Terminal rows are left -- untouched. Idempotent. Returns 'True' when a row changed. deadLetterTimer :: (Store :> es) => TimerId -> Text -> Eff es Bool deadLetterTimer timerId reason =@@ -312,6 +615,50 @@     (E.param (E.nonNullable E.uuid))     (D.rowMaybe timerRowDecoder) +lookupTimerInspectionStmt :: Statement UUID (Maybe TimerInspection)+lookupTimerInspectionStmt =+  preparable+    """+    SELECT timer_id, process_manager_name, correlation_id, fire_at,+      payload, status, attempts, fired_event_id, last_error+    FROM keiro.keiro_timers+    WHERE timer_id = $1+    """+    (E.param (E.nonNullable E.uuid))+    (D.rowMaybe timerInspectionDecoder)++findDeadTimersStmt :: Statement (Maybe Text, Int32, Maybe Text, Maybe UUID, Int64) [TimerInspection]+findDeadTimersStmt =+  preparable+    """+    SELECT timer_id, process_manager_name, correlation_id, fire_at,+      payload, status, attempts, fired_event_id, last_error+    FROM keiro.keiro_timers+    WHERE status = 'dead'+      AND ($1::text IS NULL OR process_manager_name COLLATE "C" = $1)+      AND (CASE $2::integer+        WHEN 0 THEN TRUE+        WHEN 1 THEN last_error IS NULL+        WHEN 2 THEN last_error COLLATE "C" = $3::text+        WHEN 3 THEN left(last_error, char_length($3::text)) COLLATE "C" = $3::text+        ELSE FALSE END)+      AND ($4::uuid IS NULL OR timer_id > $4)+    ORDER BY timer_id ASC+    LIMIT $5::bigint+    """+    ( contrazip5+        (E.param (E.nullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nullable E.text))+        (E.param (E.nullable E.uuid))+        (E.param (E.nonNullable E.int8))+    )+    (D.rowList timerInspectionDecoder)++timerInspectionDecoder :: D.Row TimerInspection+timerInspectionDecoder =+  TimerInspection <$> timerRowDecoder <*> D.column (D.nullable D.text)+ markTimerFiredStmt :: Statement (UUID, UUID) Bool markTimerFiredStmt =   preparable@@ -322,6 +669,7 @@         updated_at = now()     WHERE timer_id = $1       AND status = 'firing'+      AND resume_claim_token IS NULL     """     ( contrazip2         (E.param (E.nonNullable E.uuid))@@ -383,6 +731,7 @@     SET status = 'scheduled',         updated_at = now()     WHERE status = 'firing'+      AND resume_claim_token IS NULL       AND updated_at <= $1     """     (E.param (E.nonNullable E.timestamptz))@@ -397,6 +746,7 @@         updated_at = now()     WHERE timer_id = $1       AND status = 'firing'+      AND resume_claim_token IS NULL     """     (E.param (E.nonNullable E.uuid))     ((> 0) <$> D.rowsAffected)@@ -410,6 +760,7 @@         updated_at = now()     WHERE timer_id = $1       AND status IN ('scheduled', 'firing')+      AND resume_claim_token IS NULL     """     (E.param (E.nonNullable E.uuid))     ((> 0) <$> D.rowsAffected)@@ -424,6 +775,7 @@         updated_at = now()     WHERE timer_id = $1       AND status IN ('scheduled', 'firing')+      AND resume_claim_token IS NULL     """     ( contrazip2         (E.param (E.nonNullable E.uuid))
test/Main.hs view
@@ -11,6 +11,7 @@ import Control.Concurrent.MVar (MVar, modifyMVar, newEmptyMVar, newMVar, putMVar, readMVar, takeMVar, tryPutMVar) import Control.Concurrent.STM (atomically, putTMVar) import Control.Exception (ErrorCall, Exception, SomeException, displayException, evaluate, finally, throwIO, try)+import Control.Monad (forM, forM_) import Data.Aeson (object, withObject, (.:), (.:?)) import Data.Aeson qualified as Aeson import Data.Aeson.KeyMap qualified as KeyMap@@ -22,6 +23,7 @@ import Data.List (isInfixOf) import Data.List.NonEmpty qualified as NonEmpty import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes) import Data.Monoid (mempty) import Data.Set qualified as Set import Data.Text qualified as Text@@ -215,6 +217,7 @@ import Keiro.Telemetry qualified as Telemetry import Keiro.Test.Postgres   ( StoreRunner (..),+    withFreshDatabase,     withFreshResourceStore,     withFreshResourceStoreWith,     withFreshStore,@@ -223,6 +226,7 @@     withMigratedSuite,   ) import Keiro.Timer+import Keiro.Timer qualified as Timer import Keiro.Wake   ( WakeReason (..),     WakeSignal (..),@@ -5507,7 +5511,388 @@       (results, Vector.length targetEvents)         `shouldBe` ([DomainPMCommandDuplicate legacyId], 1) +  describe "Keiro.Timer foreground consumer"+    $ around+      ( \action ->+          withFreshDatabase fixture $ \connection ->+            Store.withStore (Store.defaultConnectionSettings connection) $ \firstStore ->+              Store.withStore (Store.defaultConnectionSettings connection) $ \secondStore ->+                action (firstStore, secondStore)+      )+    $ do+      it "preflights original work and invokes one callback for competing authorized resumes" $ \(firstStore, secondStore) -> do+        let original = counterTimerRequest & #payload .~ object ["memorySpace" Aeson..= ("space-a" :: Text)]+            tid = original ^. #timerId+            owner = original ^. #processManagerName+            reason = "deferred: interactive session required"+        callbacks <- newIORef (0 :: Int)+        let foreground store allowed available = do+              Right _ <- Store.runStoreIO store recoverExpiredTimerResumes+              Right inspected <- Store.runStoreIO store $ lookupTimerInspection tid+              case inspected of+                Just inspection+                  | inspection ^. #lastError == Just reason,+                    inspection ^. #timer . #payload == object ["memorySpace" Aeson..= ("space-a" :: Text)],+                    allowed,+                    available -> do+                      Right (Right claimed) <- Store.runStoreIO store $ claimDeadTimer (DeadTimerClaimRequest tid owner reason 3 60)+                      forM_ claimed $ \_ -> atomicModifyIORef' callbacks (\n -> (n + 1, ()))+                      pure claimed+                _ -> pure Nothing+        Right () <- Store.runStoreIO firstStore $ Store.runTransaction $ scheduleTimerTx original+        Right True <- Store.runStoreIO firstStore $ deadLetterTimer tid reason+        Right before <- Store.runStoreIO firstStore $ Store.runTransaction $ Tx.statement () timerReadSnapshotStmt+        -- Revocation after listing and repeated unavailable-session preflights.+        Right (Right _) <- Store.runStoreIO firstStore $ findDeadTimers (DeadTimerFilter (Just owner) (ReasonExact reason)) (DeadTimerPageRequest 10 Nothing)+        denied <- foreground firstStore False True+        isNothing denied `shouldBe` True+        forM_ [1 .. 3 :: Int] $ \_ -> do+          unavailable <- foreground firstStore True False+          isNothing unavailable `shouldBe` True+        Store.runStoreIO firstStore (Store.runTransaction (Tx.statement () timerReadSnapshotStmt)) `shouldReturn` Right before+        (a, b) <- timerRaceIO (foreground firstStore True True) (foreground secondStore True True)+        length (catMaybes [a, b]) `shouldBe` 1+        readIORef callbacks `shouldReturn` 1+        -- Crash, deterministic expiry, and recovery keep interactive work parked.+        Right () <- Store.runStoreIO firstStore $ Store.runTransaction expireTimerResumesTx+        Store.runStoreIO secondStore recoverExpiredTimerResumes `shouldReturn` Right 1+        Store.runStoreIO firstStore (runTimerWorker Nothing dueTimerTime (\_ -> error "interactive work dispatched in background")) `shouldReturn` Right Nothing+        unavailable <- foreground firstStore True False+        isNothing unavailable `shouldBe` True+        Just next <- foreground secondStore True True+        resumeClaimTimer next ^. #timerId `shouldBe` tid+        resumeClaimTimer next ^. #attempts `shouldBe` 2+        -- A transient post-claim failure consumes the attempt and retains reason.+        Store.runStoreIO secondStore (parkTimerResume next) `shouldReturn` Right True+        Right (Just parked) <- Store.runStoreIO firstStore $ lookupTimerInspection tid+        parked ^. #lastError `shouldBe` Just reason+        parked ^. #timer . #attempts `shouldBe` 2+        -- Malformed work and ordinary dead letters are application refusals.+        Right () <- Store.runStoreIO firstStore $ Store.runTransaction $ Tx.sql "UPDATE keiro.keiro_timers SET payload = '{}'::jsonb"+        malformed <- foreground firstStore True True+        isNothing malformed `shouldBe` True+        Right () <- Store.runStoreIO firstStore $ Store.runTransaction $ Tx.sql "UPDATE keiro.keiro_timers SET last_error = 'ordinary dead letter'"+        ordinary <- foreground firstStore True True+        isNothing ordinary `shouldBe` True+        readIORef callbacks `shouldReturn` 2++      it "orders renewal and completion against recovery on independent stores" $ \(firstStore, secondStore) -> do+        let tid = counterTimerRequest ^. #timerId+            request = DeadTimerClaimRequest tid (counterTimerRequest ^. #processManagerName) "deferred" 3 60+        Right () <- Store.runStoreIO firstStore $ Store.runTransaction $ scheduleTimerTx counterTimerRequest+        Right True <- Store.runStoreIO firstStore $ deadLetterTimer tid "deferred"+        Right (Right (Just claim)) <- Store.runStoreIO firstStore $ claimDeadTimer request+        (renewed, recovered) <-+          timerRaceIO+            (Store.runStoreIO firstStore $ renewTimerResume claim 60)+            (Store.runStoreIO secondStore recoverExpiredTimerResumes)+        renewed `shouldBe` Right (Right True)+        recovered `shouldBe` Right 0+        Right () <- Store.runStoreIO firstStore $ Store.runTransaction expireTimerResumesTx+        (expiredRenewal, expiredRecovery) <-+          timerRaceIO+            (Store.runStoreIO firstStore $ renewTimerResume claim 60)+            (Store.runStoreIO secondStore recoverExpiredTimerResumes)+        expiredRenewal `shouldBe` Right (Right False)+        expiredRecovery `shouldBe` Right 1+        Right (Right (Just replacement)) <- Store.runStoreIO firstStore $ claimDeadTimer request+        Right before <- Store.runStoreIO firstStore $ Store.runTransaction $ Tx.statement () timerReadSnapshotStmt+        forM_ [parkTimerResume claim, cancelTimerResume claim, completeTimerResume claim (EventId sampleUuid2)] $ \operation ->+          Store.runStoreIO firstStore operation `shouldReturn` Right False+        Store.runStoreIO firstStore (Store.runTransaction (Tx.statement () timerReadSnapshotStmt)) `shouldReturn` Right before+        Right () <- Store.runStoreIO firstStore $ Store.runTransaction expireTimerResumesTx+        (expiredCompletion, completionRecovery) <-+          timerRaceIO+            (Store.runStoreIO firstStore $ completeTimerResume replacement (EventId sampleUuid2))+            (Store.runStoreIO secondStore recoverExpiredTimerResumes)+        expiredCompletion `shouldBe` Right False+        completionRecovery `shouldBe` Right 1+        Right (Right (Just finalClaim)) <- Store.runStoreIO firstStore $ claimDeadTimer request+        (completed, noRecovery) <-+          timerRaceIO+            (Store.runStoreIO firstStore $ completeTimerResume finalClaim (EventId sampleUuid2))+            (Store.runStoreIO secondStore recoverExpiredTimerResumes)+        completed `shouldBe` Right True+        noRecovery `shouldBe` Right 0+   describe "Keiro.Timer" $ around (withFreshStore fixture) $ do+    it "guards dead resume ownership and retains attempts when parked" $ \storeHandle -> do+      let tid = counterTimerRequest ^. #timerId+          request = DeadTimerClaimRequest tid (counterTimerRequest ^. #processManagerName) "deferred" 2 60+      Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ scheduleTimerTx counterTimerRequest+      Right True <- Store.runStoreIO storeHandle $ deadLetterTimer tid "deferred"+      Right (Right (Just claim)) <- Store.runStoreIO storeHandle $ claimDeadTimer request+      resumeClaimTimer claim ^. #attempts `shouldBe` 1+      Store.runStoreIO storeHandle (claimDueTimer dueTimerTime) `shouldReturn` Right Nothing+      Store.runStoreIO storeHandle (markTimerFired tid (EventId sampleUuid2)) `shouldReturn` Right False+      Store.runStoreIO storeHandle (cancelTimer tid) `shouldReturn` Right False+      Store.runStoreIO storeHandle (deadLetterTimer tid "wrong") `shouldReturn` Right False+      Store.runStoreIO storeHandle (requeueStuckTimer tid) `shouldReturn` Right False+      Store.runStoreIO storeHandle (requeueStuckTimers 0 (resumeClaimLeaseUntil claim)) `shouldReturn` Right 0+      Store.runStoreIO storeHandle (renewTimerResume claim 60) `shouldReturn` Right (Right True)+      Store.runStoreIO storeHandle (parkTimerResume claim) `shouldReturn` Right True+      Right (Right (Just replacement)) <- Store.runStoreIO storeHandle $ claimDeadTimer request+      resumeClaimTimer replacement ^. #attempts `shouldBe` 2+      Store.runStoreIO storeHandle (completeTimerResume claim (EventId sampleUuid2)) `shouldReturn` Right False+      Store.runStoreIO storeHandle (parkTimerResume claim) `shouldReturn` Right False+      Store.runStoreIO storeHandle (cancelTimerResume claim) `shouldReturn` Right False+      Store.runStoreIO storeHandle (renewTimerResume claim 60) `shouldReturn` Right (Right False)+      Store.runStoreIO storeHandle (parkTimerResume replacement) `shouldReturn` Right True+      Right (Right refused) <- Store.runStoreIO storeHandle $ claimDeadTimer request+      isNothing refused `shouldBe` True+      Right (Just observed) <- Store.runStoreIO storeHandle $ lookupTimerInspection tid+      observed ^. #lastError `shouldBe` Just "deferred"+      observed ^. #timer . #attempts `shouldBe` 2++    it "refuses every ineligible claim without changing any persisted column" $ \storeHandle -> do+      let tid = counterTimerRequest ^. #timerId+          request = DeadTimerClaimRequest tid (counterTimerRequest ^. #processManagerName) "deferred" 1 60+          snapshot = Store.runStoreIO storeHandle $ Store.runTransaction $ Tx.statement () timerReadSnapshotStmt+          refused req = do+            before <- snapshot+            Right (Right result) <- Store.runStoreIO storeHandle $ claimDeadTimer req+            isNothing result `shouldBe` True+            snapshot `shouldReturn` before+      refused request+      Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ scheduleTimerTx counterTimerRequest+      refused request+      Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      refused request+      Right True <- Store.runStoreIO storeHandle $ deadLetterTimer tid "deferred"+      refused (request & #processManagerName .~ "COUNTER")+      refused (request & #expectedReason .~ "Deferred")+      refused (request & #maxAttempts .~ 0)+      refused request -- ordinary claim already consumed the ceiling+      before <- snapshot+      Right (Left badMax) <- Store.runStoreIO storeHandle $ claimDeadTimer (request & #maxAttempts .~ (-1))+      badMax `shouldBe` InvalidTimerResumeMaxAttempts (-1)+      forM_ [0, -1, maxBound] $ \seconds -> do+        Right (Left badLease) <- Store.runStoreIO storeHandle $ claimDeadTimer (request & #leaseSeconds .~ seconds)+        badLease `shouldBe` InvalidTimerResumeLeaseSeconds seconds+      snapshot `shouldReturn` before+      Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ Tx.statement (case tid of TimerId uuid -> uuid) legacyDeadTimerReasonStmt+      refused (request & #maxAttempts .~ 2)++    it "claims literal empty and Unicode reasons and preserves original work" $ \storeHandle -> do+      forM_ (zip [1 ..] ["", "延期: café 日本語 🌱", "a%_\\'雪"]) $ \(n, reason) -> do+        let original = counterTimerRequest & #timerId .~ TimerId (UUID.fromWords 0 0 0 n)+            tid = original ^. #timerId+            request = DeadTimerClaimRequest tid (original ^. #processManagerName) reason 1 60+        Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ scheduleTimerTx original+        Right True <- Store.runStoreIO storeHandle $ deadLetterTimer tid reason+        Right (Just before) <- Store.runStoreIO storeHandle $ lookupTimer tid+        Right (Right (Just claim)) <- Store.runStoreIO storeHandle $ claimDeadTimer request+        resumeClaimTimer claim `shouldBe` (before & #status .~ Firing & #attempts .~ 1)+        Right (Right repeated) <- Store.runStoreIO storeHandle $ claimDeadTimer request+        isNothing repeated `shouldBe` True+        Store.runStoreIO storeHandle (completeTimerResume claim (EventId sampleUuid2)) `shouldReturn` Right True+        Right (Just inspection) <- Store.runStoreIO storeHandle $ lookupTimerInspection tid+        inspection ^. #lastError `shouldBe` Just reason+        inspection ^. #timer . #firedEventId `shouldBe` Just (EventId sampleUuid2)+        Right (Right terminal) <- Store.runStoreIO storeHandle $ claimDeadTimer (request & #maxAttempts .~ 2)+        isNothing terminal `shouldBe` True++    it "expires without revival and re-parks independently of ordinary recovery" $ \storeHandle -> do+      let tid = counterTimerRequest ^. #timerId+          request = DeadTimerClaimRequest tid (counterTimerRequest ^. #processManagerName) "deferred" 3 60+      Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ scheduleTimerTx counterTimerRequest+      Right True <- Store.runStoreIO storeHandle $ deadLetterTimer tid "deferred"+      Right (Right (Just old)) <- Store.runStoreIO storeHandle $ claimDeadTimer request+      Right () <- Store.runStoreIO storeHandle $ Store.runTransaction expireTimerResumesTx+      Right before <- Store.runStoreIO storeHandle $ Store.runTransaction $ Tx.statement () timerReadSnapshotStmt+      Store.runStoreIO storeHandle (renewTimerResume old 60) `shouldReturn` Right (Right False)+      Store.runStoreIO storeHandle (completeTimerResume old (EventId sampleUuid2)) `shouldReturn` Right False+      Store.runStoreIO storeHandle (parkTimerResume old) `shouldReturn` Right False+      Store.runStoreIO storeHandle (cancelTimerResume old) `shouldReturn` Right False+      Store.runStoreIO storeHandle (Store.runTransaction (Tx.statement () timerReadSnapshotStmt)) `shouldReturn` Right before+      let options = defaultTimerWorkerOptions & #requeueStuckAfter .~ Nothing+      Store.runStoreIO storeHandle (runTimerWorkerWith Nothing options dueTimerTime (\_ -> error "foreground work reached background")) `shouldReturn` Right Nothing+      Store.runStoreIO storeHandle recoverExpiredTimerResumes `shouldReturn` Right 0+      Right (Just parked) <- Store.runStoreIO storeHandle $ lookupTimerInspection tid+      parked ^. #timer . #status `shouldBe` Dead+      parked ^. #timer . #attempts `shouldBe` 1+      parked ^. #lastError `shouldBe` Just "deferred"+      Right (Right (Just replacement)) <- Store.runStoreIO storeHandle $ claimDeadTimer request+      Store.runStoreIO storeHandle (completeTimerResume old (EventId sampleUuid2)) `shouldReturn` Right False+      Store.runStoreIO storeHandle (renewTimerResume old 60) `shouldReturn` Right (Right False)+      Store.runStoreIO storeHandle (cancelTimerResume replacement) `shouldReturn` Right True+      Right (Right cancelled) <- Store.runStoreIO storeHandle $ claimDeadTimer request+      isNothing cancelled `shouldBe` True++    it "inspects absent timers and preserves legacy metadata through every lifecycle" $ \storeHandle -> do+      let tid = counterTimerRequest ^. #timerId+          inspect reason = do+            Right old <- Store.runStoreIO storeHandle $ lookupTimer tid+            Right observed <- Store.runStoreIO storeHandle $ lookupTimerInspection tid+            fmap (^. #timer) observed `shouldBe` old+            fmap (^. #lastError) observed `shouldBe` Just reason+      Store.runStoreIO storeHandle (lookupTimerInspection tid) `shouldReturn` Right Nothing+      Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ scheduleTimerTx counterTimerRequest+      inspect Nothing+      Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      inspect Nothing+      Right True <- Store.runStoreIO storeHandle $ markTimerFired tid (EventId sampleUuid2)+      inspect Nothing+      Right (Just observed) <- Store.runStoreIO storeHandle $ lookupTimerInspection tid+      observed ^. #timer . #firedEventId `shouldBe` Just (EventId sampleUuid2)+      observed ^. #timer . #attempts `shouldBe` 1++    it "preserves empty, populated, and Unicode dead reasons verbatim" $ \storeHandle -> do+      forM_ (zip [1 ..] ["", " retry exhausted ", "延期: café 日本語 🌱"]) $ \(n, reason) -> do+        let request = counterTimerRequest & #timerId .~ TimerId (UUID.fromWords 0 0 0 n)+        Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ scheduleTimerTx request+        Right True <- Store.runStoreIO storeHandle $ deadLetterTimer (request ^. #timerId) reason+        Right old <- Store.runStoreIO storeHandle $ lookupTimer (request ^. #timerId)+        Right (Just observed) <- Store.runStoreIO storeHandle $ lookupTimerInspection (request ^. #timerId)+        Just (observed ^. #timer) `shouldBe` old+        observed ^. #lastError `shouldBe` Just reason++    it "filters dead timers by exact owner and literal reason, preserving NULL" $ \storeHandle -> do+      let fixtures =+            [ (1, "A", Just "deferred: one"),+              (2, "A", Just "deferred: 二"),+              (3, "B", Just "deferred: three"),+              (4, "A", Just "ordinary"),+              (5, "A", Nothing),+              (6, "A", Just ""),+              (7, "A", Just "a%_\\'雪 tail"),+              (8, "A", Just "aXX雪 tail"),+              (9, "a", Just "Deferred: one")+            ]+          tid n = TimerId (UUID.fromWords 0 0 0 n)+          check owner reason expected = do+            Right (Right page) <-+              Store.runStoreIO storeHandle $+                findDeadTimers (DeadTimerFilter owner reason) (DeadTimerPageRequest 100 Nothing)+            fmap (^. #timer . #timerId) (page ^. #timers) `shouldBe` fmap tid expected+            page ^. #nextAfterTimerId `shouldBe` Nothing+      forM_ fixtures $ \(n, owner, reason) -> do+        let request = counterTimerRequest & #timerId .~ tid n & #processManagerName .~ owner+        Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ scheduleTimerTx request+        Right True <- Store.runStoreIO storeHandle $ deadLetterTimer (tid n) (fromMaybe "legacy" reason)+        when (isNothing reason) $ do+          Right () <-+            Store.runStoreIO storeHandle $+              Store.runTransaction $+                Tx.statement (UUID.fromWords 0 0 0 n) legacyDeadTimerReasonStmt+          Right (Just inspection) <- Store.runStoreIO storeHandle $ lookupTimerInspection (tid n)+          inspection ^. #lastError `shouldBe` Nothing+      -- Include every non-dead lifecycle in the same manager/reason search space.+      forM_ [10 .. 13] $ \n -> do+        Right () <-+          Store.runStoreIO storeHandle $+            Store.runTransaction $+              scheduleTimerTx (counterTimerRequest & #timerId .~ tid n & #processManagerName .~ "A")+        pure ()+      Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      Right True <- Store.runStoreIO storeHandle $ markTimerFired (tid 10) (EventId sampleUuid2)+      Right (Just _) <- Store.runStoreIO storeHandle $ claimDueTimer dueTimerTime+      Right True <- Store.runStoreIO storeHandle $ cancelTimer (tid 12)+      Right (Just cancelled) <- Store.runStoreIO storeHandle $ lookupTimerInspection (tid 12)+      cancelled ^. #timer . #status `shouldBe` Timer.Cancelled+      check Nothing AnyTimerReason [1 .. 9]+      check (Just "A") (ReasonPrefix "deferred:") [1, 2]+      check Nothing (ReasonPrefix "deferred:") [1, 2, 3]+      check (Just "a") AnyTimerReason [9]+      check Nothing ReasonAbsent [5]+      check Nothing (ReasonExact "") [6]+      check Nothing (ReasonPrefix "") [1, 2, 3, 4, 6, 7, 8, 9]+      check Nothing (ReasonExact "deferred: 二") [2]+      check Nothing (ReasonPrefix "a%_\\'雪") [7]+      check Nothing (ReasonExact "a%_\\'雪 tail") [7]+      check Nothing (ReasonExact "DEFERRED: one") []+      check (Just "A' OR TRUE --") AnyTimerReason []++    it "bounds pages, traverses UUID order, and leaves every stored column unchanged" $ \storeHandle -> do+      forM_ [1 .. 101] $ \n -> do+        let request = plainTimerRequest n+        Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ scheduleTimerTx request+        Right True <- Store.runStoreIO storeHandle $ deadLetterTimer (request ^. #timerId) "deferred"+        pure ()+      let snapshot = Store.runStoreIO storeHandle $ Store.runTransaction $ Tx.statement () timerReadSnapshotStmt+          readPage size cursor = Store.runStoreIO storeHandle $ findDeadTimers anyDeadTimer (DeadTimerPageRequest size cursor)+      storedBefore <- snapshot+      forM_ [-1, 0, 101, maxBound] $ \size ->+        readPage size Nothing `shouldReturn` Right (Left (InvalidDeadTimerPageSize size))+      Right (Right first) <- readPage 100 Nothing+      length (first ^. #timers) `shouldBe` 100+      first ^. #nextAfterTimerId `shouldBe` Just (plainTimerRequest 100 ^. #timerId)+      readPage 100 Nothing `shouldReturn` Right (Right first)+      Right (Right finalPage) <- readPage 100 (first ^. #nextAfterTimerId)+      fmap (^. #timer . #timerId) (finalPage ^. #timers) `shouldBe` [plainTimerRequest 101 ^. #timerId]+      finalPage ^. #nextAfterTimerId `shouldBe` Nothing+      readPage 1 (Just (plainTimerRequest 101 ^. #timerId)) `shouldReturn` Right (Right (DeadTimerPage [] Nothing))+      let walk cursor = do+            Right (Right page) <- readPage 1 cursor+            let ids = fmap (^. #timer . #timerId) (page ^. #timers)+            case page ^. #nextAfterTimerId of+              Nothing -> pure ids+              next -> (ids <>) <$> walk next+      walk Nothing `shouldReturn` fmap ((^. #timerId) . plainTimerRequest) [1 .. 101]+      Right (Just _) <- Store.runStoreIO storeHandle $ lookupTimerInspection (plainTimerRequest 1 ^. #timerId)+      snapshot `shouldReturn` storedBefore+      Store.runStoreIO storeHandle (runTimerWorker Nothing dueTimerTime (\_ -> pure (Just (EventId sampleUuid2))))+        `shouldReturn` Right Nothing+      snapshot `shouldReturn` storedBefore++    it "continues after a deleted cursor and observes new eligibility only above it" $ \storeHandle -> do+      let add n = do+            Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ scheduleTimerTx (plainTimerRequest n)+            Right True <- Store.runStoreIO storeHandle $ deadLetterTimer (plainTimerRequest n ^. #timerId) "deferred"+            pure ()+          readPage cursor = Store.runStoreIO storeHandle $ findDeadTimers anyDeadTimer (DeadTimerPageRequest 1 cursor)+      mapM_ add [20, 40, 60]+      Right (Right first) <- readPage Nothing+      first ^. #nextAfterTimerId `shouldBe` Just (plainTimerRequest 20 ^. #timerId)+      Right () <-+        Store.runStoreIO storeHandle $+          Store.runTransaction $+            Tx.sql+              "DELETE FROM keiro.keiro_timers WHERE correlation_id = 'drain-20'; UPDATE keiro.keiro_timers SET status = 'cancelled' WHERE correlation_id = 'drain-40'"+      mapM_ add [10, 30]+      Right (Right second) <- readPage (first ^. #nextAfterTimerId)+      fmap (^. #timer . #timerId) (second ^. #timers) `shouldBe` [plainTimerRequest 30 ^. #timerId]+      Right (Right third) <- readPage (second ^. #nextAfterTimerId)+      fmap (^. #timer . #timerId) (third ^. #timers) `shouldBe` [plainTimerRequest 60 ^. #timerId]+      third ^. #nextAfterTimerId `shouldBe` Nothing++    it "renders authorized original work beyond empty pages and rechecks revoked permissions" $ \storeHandle -> do+      let reason = "kioku:deferred:interactive-unavailable feature=summary details=保持"+          deadFilter = DeadTimerFilter (Just "drain-pm") (ReasonPrefix "kioku:deferred:interactive-unavailable feature=")+          entries =+            [ (1, object ["space" Aeson..= ("hidden" :: Text), "work" Aeson..= ("secret" :: Text)]),+              (2, object ["invalid" Aeson..= ("never render" :: Text)]),+              (3, object ["space" Aeson..= ("allowed" :: Text), "work" Aeson..= ("original work" :: Text)]),+              (4, object ["space" Aeson..= ("allowed" :: Text), "work" Aeson..= ("revoked work" :: Text)])+            ]+      forM_ entries $ \(n, payload) -> do+        Right () <- Store.runStoreIO storeHandle $ Store.runTransaction $ scheduleTimerTx (plainTimerRequest n & #payload .~ payload)+        Right True <- Store.runStoreIO storeHandle $ deadLetterTimer (plainTimerRequest n ^. #timerId) reason+        pure ()+      permissions <- newIORef (Set.singleton ("allowed" :: Text))+      let renderPage cursor = do+            Right (Right page) <- Store.runStoreIO storeHandle $ findDeadTimers deadFilter (DeadTimerPageRequest 1 cursor)+            fresh <- readIORef permissions+            rendered <- fmap catMaybes $ forM (page ^. #timers) $ \listed -> do+              Right inspected <- Store.runStoreIO storeHandle $ lookupTimerInspection (listed ^. #timer . #timerId)+              pure $ do+                inspection <- inspected+                (space, work) <-+                  either (const Nothing) Just $+                    parseEither (withObject "work" (\o -> (,) <$> o .: "space" <*> o .: "work")) (inspection ^. #timer . #payload)+                if Set.member space fresh then Just (work :: Text, inspection ^. #lastError) else Nothing+            pure (rendered, page ^. #nextAfterTimerId)+      (hidden, next1) <- renderPage Nothing+      hidden `shouldBe` []+      next1 `shouldSatisfy` isJust+      (malformed, next2) <- renderPage next1+      malformed `shouldBe` []+      next2 `shouldSatisfy` isJust+      (allowed, next3) <- renderPage next2+      allowed `shouldBe` [("original work", Just reason)]+      writeIORef permissions Set.empty+      renderPage next3 `shouldReturn` ([], Nothing)+     it "validates worker options before startup" $ \_storeHandle -> do       shouldBeRight_ (mkTimerWorkerOptions defaultTimerWorkerOptions)       mkTimerWorkerOptions (defaultTimerWorkerOptions & #maxAttempts ?~ (-1))@@ -16462,3 +16847,38 @@         (E.param (E.nonNullable E.text))     )     (D.singleRow (D.column (D.nonNullable D.int8)))++-- Test-only legacy fixture and complete persistence snapshot, including timestamps.+legacyDeadTimerReasonStmt :: Statement UUID ()+legacyDeadTimerReasonStmt =+  preparable+    "UPDATE keiro.keiro_timers SET last_error = NULL WHERE timer_id = $1"+    (E.param (E.nonNullable E.uuid))+    D.noResult++timerReadSnapshotStmt :: Statement () [Value]+timerReadSnapshotStmt =+  preparable+    "SELECT to_jsonb(t) FROM keiro.keiro_timers t ORDER BY timer_id"+    E.noParams+    (D.rowList (D.column (D.nonNullable D.jsonb)))++-- Test-only deterministic expiry; foreground consumer code uses public APIs.+expireTimerResumesTx :: Tx.Transaction ()+expireTimerResumesTx = Tx.sql "UPDATE keiro.keiro_timers SET resume_lease_until = clock_timestamp() - interval '1 second' WHERE resume_claim_token IS NOT NULL"++-- Both independent connections begin only after the common barrier opens.+-- Exceptions are transported to the test thread instead of stranding its wait.+timerRaceIO :: IO a -> IO b -> IO (a, b)+timerRaceIO first second = do+  start <- newEmptyMVar+  a <- newEmptyMVar+  b <- newEmptyMVar+  let capture :: IO x -> IO (Either SomeException x)+      capture = try+  _ <- forkIO $ capture (readMVar start >> first) >>= putMVar a+  _ <- forkIO $ capture (readMVar start >> second) >>= putMVar b+  putMVar start ()+  ar <- takeMVar a >>= either throwIO pure+  br <- takeMVar b >>= either throwIO pure+  pure (ar, br)