keiro-ops 0.21.0.0 → 0.22.0.0
raw patch · 17 files changed
+1164/−239 lines, 17 filesdep +hasql-pooldep +keikidep ~keirodep ~keiro-migrationsdep ~keiro-pgmqPVP ok
version bump matches the API change (PVP)
Dependencies added: hasql-pool, keiki
Dependency ranges changed: keiro, keiro-migrations, keiro-pgmq, keiro-test-support, kiroku-store, pgmq-migration
API changes (from Hackage documentation)
+ Keiro.Ops: Aggregate :: Command -> Command
+ Keiro.Ops: Inbox :: Command -> Command
+ Keiro.Ops: OpsInvocation :: !GlobalOptions -> !Command -> OpsInvocation
+ Keiro.Ops: Outbox :: Command -> Command
+ Keiro.Ops: Pgmq :: Command -> Command
+ Keiro.Ops: ProcessManager :: Command -> Command
+ Keiro.Ops: Projection :: Command -> Command
+ Keiro.Ops: Rebuild :: Command -> Command
+ Keiro.Ops: ReplayAudit :: Command -> Command
+ Keiro.Ops: Shard :: Command -> Command
+ Keiro.Ops: Snapshot :: Command -> Command
+ Keiro.Ops: Stream :: Command -> Command
+ Keiro.Ops: Timer :: Command -> Command
+ Keiro.Ops: Workflow :: Command -> Command
+ Keiro.Ops: [aggregates] :: AppHooks -> !Maybe AggregateInspectors
+ Keiro.Ops: [globalOptions] :: OpsInvocation -> !GlobalOptions
+ Keiro.Ops: [opsCommand] :: OpsInvocation -> !Command
+ Keiro.Ops: [processManagers] :: AppHooks -> !Maybe ProcessManagerInspectors
+ Keiro.Ops: commandParser :: AppHooks -> Parser Command
+ Keiro.Ops: data Command
+ Keiro.Ops: isMutation :: Command -> Bool
+ Keiro.Ops: runCommand :: AppHooks -> OpsEnv -> Command -> IO OpsOutcome
+ Keiro.Ops: runCommandWithHeaderCache :: WorkflowHeaderCache -> AppHooks -> OpsEnv -> Command -> IO (OpsOutcome, Maybe ByteString)
+ Keiro.Ops: runCommandWithReadEncoding :: (Encoding -> IO (Maybe ByteString)) -> WorkflowHeaderCache -> AppHooks -> OpsEnv -> Command -> IO (OpsOutcome, Maybe ByteString)
+ Keiro.Ops: type AggregateInspectors = Map Text SomeAggregateInspector
+ Keiro.Ops: type ProcessManagerInspectors = Map Text SomeProcessManagerInspector
+ Keiro.Ops: validateCommand :: Command -> Either (Text, Text) ()
+ Keiro.Ops.Aggregate: List :: !ListOptions -> Command
+ Keiro.Ops.Aggregate: ListOptions :: !Text -> !Int -> !Maybe InspectionCursor -> ListOptions
+ Keiro.Ops.Aggregate: Show :: !ShowOptions -> Command
+ Keiro.Ops.Aggregate: ShowOptions :: !Text -> !Text -> ShowOptions
+ Keiro.Ops.Aggregate: Snapshot :: !ShowOptions -> Command
+ Keiro.Ops.Aggregate: Types :: Command
+ Keiro.Ops.Aggregate: [after] :: ListOptions -> !Maybe InspectionCursor
+ Keiro.Ops.Aggregate: [aggregateId] :: ShowOptions -> !Text
+ Keiro.Ops.Aggregate: [limit] :: ListOptions -> !Int
+ Keiro.Ops.Aggregate: [name] :: ListOptions -> !Text
+ Keiro.Ops.Aggregate: commandParser :: Parser Command
+ Keiro.Ops.Aggregate: data Command
+ Keiro.Ops.Aggregate: data ListOptions
+ Keiro.Ops.Aggregate: data ShowOptions
+ Keiro.Ops.Aggregate: instance GHC.Classes.Eq Keiro.Ops.Aggregate.Command
+ Keiro.Ops.Aggregate: instance GHC.Classes.Eq Keiro.Ops.Aggregate.ListOptions
+ Keiro.Ops.Aggregate: instance GHC.Classes.Eq Keiro.Ops.Aggregate.ShowOptions
+ Keiro.Ops.Aggregate: instance GHC.Internal.Show.Show Keiro.Ops.Aggregate.Command
+ Keiro.Ops.Aggregate: instance GHC.Internal.Show.Show Keiro.Ops.Aggregate.ListOptions
+ Keiro.Ops.Aggregate: instance GHC.Internal.Show.Show Keiro.Ops.Aggregate.ShowOptions
+ Keiro.Ops.Aggregate: isMutation :: Command -> Bool
+ Keiro.Ops.Aggregate: runCommand :: OpsEnv -> AggregateInspectors -> Command -> IO OpsOutcome
+ Keiro.Ops.Aggregate: validateCommand :: Command -> Either (Text, Text) ()
+ Keiro.Ops.Embed: [aggregates] :: AppHooks -> !Maybe AggregateInspectors
+ Keiro.Ops.Embed: [processManagers] :: AppHooks -> !Maybe ProcessManagerInspectors
+ Keiro.Ops.Embed: type AggregateInspectors = Map Text SomeAggregateInspector
+ Keiro.Ops.Embed: type ProcessManagerInspectors = Map Text SomeProcessManagerInspector
+ Keiro.Ops.Parse: cursorReader :: ReadM InspectionCursor
+ Keiro.Ops.ProcessManager: List :: !ListOptions -> Command
+ Keiro.Ops.ProcessManager: ListOptions :: !Text -> !Maybe InspectionCursor -> !Int -> ListOptions
+ Keiro.Ops.ProcessManager: Show :: !ShowOptions -> Command
+ Keiro.Ops.ProcessManager: ShowOptions :: !Text -> !Text -> !Int -> ShowOptions
+ Keiro.Ops.ProcessManager: Types :: Command
+ Keiro.Ops.ProcessManager: [after] :: ListOptions -> !Maybe InspectionCursor
+ Keiro.Ops.ProcessManager: [correlationId] :: ShowOptions -> !Text
+ Keiro.Ops.ProcessManager: [limit] :: ListOptions -> !Int
+ Keiro.Ops.ProcessManager: [name] :: ListOptions -> !Text
+ Keiro.Ops.ProcessManager: [timerLimit] :: ShowOptions -> !Int
+ Keiro.Ops.ProcessManager: commandParser :: Parser Command
+ Keiro.Ops.ProcessManager: data Command
+ Keiro.Ops.ProcessManager: data ListOptions
+ Keiro.Ops.ProcessManager: data ShowOptions
+ Keiro.Ops.ProcessManager: instance GHC.Classes.Eq Keiro.Ops.ProcessManager.Command
+ Keiro.Ops.ProcessManager: instance GHC.Classes.Eq Keiro.Ops.ProcessManager.ListOptions
+ Keiro.Ops.ProcessManager: instance GHC.Classes.Eq Keiro.Ops.ProcessManager.ShowOptions
+ Keiro.Ops.ProcessManager: instance GHC.Internal.Show.Show Keiro.Ops.ProcessManager.Command
+ Keiro.Ops.ProcessManager: instance GHC.Internal.Show.Show Keiro.Ops.ProcessManager.ListOptions
+ Keiro.Ops.ProcessManager: instance GHC.Internal.Show.Show Keiro.Ops.ProcessManager.ShowOptions
+ Keiro.Ops.ProcessManager: isMutation :: Command -> Bool
+ Keiro.Ops.ProcessManager: prepareTypes :: ProcessManagerInspectors -> (OpsResult, Encoding)
+ Keiro.Ops.ProcessManager: runCommand :: ProcessManagerInspectors -> OpsEnv -> Command -> IO OpsOutcome
+ Keiro.Ops.ProcessManager: validateCommand :: Command -> Either (Text, Text) ()
+ Keiro.Ops.Projection: ProjectionStatusOptions :: !Int -> !Maybe InspectionCursor -> ProjectionStatusOptions
+ Keiro.Ops.Projection: Status :: !ProjectionStatusOptions -> Command
+ Keiro.Ops.Projection: [after] :: ProjectionStatusOptions -> !Maybe InspectionCursor
+ Keiro.Ops.Projection: [limit] :: ProjectionStatusOptions -> !Int
+ Keiro.Ops.Projection: data ProjectionStatusOptions
+ Keiro.Ops.Projection: instance GHC.Classes.Eq Keiro.Ops.Projection.ProjectionStatusOptions
+ Keiro.Ops.Projection: instance GHC.Internal.Show.Show Keiro.Ops.Projection.ProjectionStatusOptions
+ Keiro.Ops.Projection: projectionGroupStatusJson :: ProjectionGroupStatusV1 -> Value
+ Keiro.Ops.Projection: validateCommand :: Command -> Either (Text, Text) ()
+ Keiro.Ops.Render: InspectionFailed :: !Text -> !Text -> OpsOutcome
+ Keiro.Ops.Shard: List :: Command
+ Keiro.Ops.Timer: PendingList :: !PendingListOptions -> Command
+ Keiro.Ops.Timer: PendingListOptions :: !Maybe Text -> !Maybe Text -> !Maybe InspectionCursor -> !Int -> PendingListOptions
+ Keiro.Ops.Timer: [after] :: PendingListOptions -> !Maybe InspectionCursor
+ Keiro.Ops.Timer: [correlation] :: PendingListOptions -> !Maybe Text
+ Keiro.Ops.Timer: [manager] :: PendingListOptions -> !Maybe Text
+ Keiro.Ops.Timer: data PendingListOptions
+ Keiro.Ops.Timer: instance GHC.Classes.Eq Keiro.Ops.Timer.PendingListOptions
+ Keiro.Ops.Timer: instance GHC.Internal.Show.Show Keiro.Ops.Timer.PendingListOptions
+ Keiro.Ops.Timer: pendingTimerItemJson :: UTCTime -> TimerInspection -> Value
+ Keiro.Ops.Timer: validateCommand :: Command -> Either (Text, Text) ()
+ Keiro.Ops.Workflow: Inspect :: !InspectionCommand -> Command
+ Keiro.Ops.Workflow: InspectAwakeables :: !WorkflowRef -> !OwnerPageQuery -> InspectionCommand
+ Keiro.Ops.Workflow: InspectChildren :: !WorkflowRef -> !OwnerPageQuery -> InspectionCommand
+ Keiro.Ops.Workflow: InspectHeader :: !WorkflowRef -> InspectionCommand
+ Keiro.Ops.Workflow: InspectJournal :: !WorkflowRef -> !DetailPageQuery -> InspectionCommand
+ Keiro.Ops.Workflow: InspectList :: !WorkflowListQuery -> InspectionCommand
+ Keiro.Ops.Workflow: InspectSteps :: !WorkflowRef -> !DetailPageQuery -> InspectionCommand
+ Keiro.Ops.Workflow: data InspectionCommand
+ Keiro.Ops.Workflow: headerOutcome :: WorkflowRef -> Maybe WorkflowInstanceRow -> OpsOutcome
+ Keiro.Ops.Workflow: instance GHC.Classes.Eq Keiro.Ops.Workflow.InspectionCommand
+ Keiro.Ops.Workflow: instance GHC.Internal.Show.Show Keiro.Ops.Workflow.InspectionCommand
+ Keiro.Ops.Workflow: validateCommand :: Command -> Either (Text, Text) ()
+ Keiro.Ops.WorkflowHeaderCache: data WorkflowHeaderCache
+ Keiro.Ops.WorkflowHeaderCache: newWorkflowHeaderCache :: Int -> (Value -> IO (Maybe ByteString)) -> IO WorkflowHeaderCache
+ Keiro.Ops.WorkflowHeaderCache: runHeaderCached :: WorkflowHeaderCache -> OpsEnv -> WorkflowRef -> IO (OpsOutcome, Maybe ByteString)
+ Keiro.Ops.WorkflowHeaderCache: workflowHeaderCacheUsage :: WorkflowHeaderCache -> IO (Int, Integer)
- Keiro.Ops: AppHooks :: !Maybe ResumeHook -> !Maybe TimerFire -> !Maybe OpsAuditConfig -> !Maybe ProjectionCatalogOperations -> AppHooks
+ Keiro.Ops: AppHooks :: !Maybe ResumeHook -> !Maybe TimerFire -> !Maybe OpsAuditConfig -> !Maybe ProjectionCatalogOperations -> !Maybe AggregateInspectors -> !Maybe ProcessManagerInspectors -> AppHooks
- Keiro.Ops.Embed: AppHooks :: !Maybe ResumeHook -> !Maybe TimerFire -> !Maybe OpsAuditConfig -> !Maybe ProjectionCatalogOperations -> AppHooks
+ Keiro.Ops.Embed: AppHooks :: !Maybe ResumeHook -> !Maybe TimerFire -> !Maybe OpsAuditConfig -> !Maybe ProjectionCatalogOperations -> !Maybe AggregateInspectors -> !Maybe ProcessManagerInspectors -> AppHooks
- Keiro.Ops.Timer: [limit] :: DrainOptions -> !Int
+ Keiro.Ops.Timer: [limit] :: PendingListOptions -> !Int
Files
- CHANGELOG.md +51/−0
- keiro-ops.cabal +16/−10
- src/Keiro/Ops.hs +70/−2
- src/Keiro/Ops/Aggregate.hs +121/−0
- src/Keiro/Ops/Embed.hs +10/−2
- src/Keiro/Ops/Parse.hs +8/−1
- src/Keiro/Ops/Pgmq.hs +5/−3
- src/Keiro/Ops/ProcessManager.hs +136/−0
- src/Keiro/Ops/Projection.hs +78/−3
- src/Keiro/Ops/Render.hs +1/−0
- src/Keiro/Ops/Shard.hs +59/−34
- src/Keiro/Ops/Snapshot.hs +23/−1
- src/Keiro/Ops/Timer.hs +74/−7
- src/Keiro/Ops/Workflow.hs +143/−175
- src/Keiro/Ops/WorkflowHeaderCache.hs +103/−0
- test/Main.hs +96/−1
- test/ReactionInspectionSpec.hs +170/−0
CHANGELOG.md view
@@ -6,6 +6,57 @@ ## [Unreleased] +## 0.22.0.0 — 2026-10-27++### Breaking Changes++- Require the Kiroku Store 0.12 cohort from+ mori://shinzui/kiroku/packages/kiroku-store; apply migration 0016 from+ mori://shinzui/kiroku/packages/kiroku-store-migrations with affected workers+ stopped, then restart new binaries before using namespace targets. Update+ exhaustive subscription target/event matches and low-level record constructors.++- Direct `AppHooks` records must supply `aggregates` and `processManagers`; `emptyAppHooks` leaves them disabled. `OpsOutcome` adds `InspectionFailed`, requiring exhaustive consumers to handle structured codes.+- `wf show/steps/journal` return `InspectionFailed` for typed inspection errors. `pgmq dlq read` requires `--force` to reserve rows; an unforced invocation previews and exits 1.++### New Features++- Add embedded `pm types` with shared JSON and human metadata output, including+ empty catalogs and aliases, without requiring a manager name or store reads.+ Hosts may supply a bounded direct encoder through `runCommandWithReadEncoding`.++- Add bounded projection-status pages and `projection status`, preserving the+ frozen v1 relation and complete-list compatibility API. Page keys precede+ correlated status reads; no writer index is added. HTTP feed protocol and+ canonical view definitions are available as foundations for live feeds.++- Add process-manager persisted-state inspection and bounded raw stream listing,+ pending-timer pages with filter-bound cursors, and database-clock shard+ ownership/listing. Reads add no worker queries, polling tasks or migrations.+ Embedded `pm show/list` uses the additive `AppHooks.processManagers` field;+ `emptyAppHooks` leaves this optional catalog off.+++- An optional `AppHooks.aggregates` catalog mounts read-only `aggregate types|list|show|snapshot` commands. Snapshot show adds `observed_at` and `age_seconds`.++- Bounded header presentation reuse through an alternative runner that checks the exact current row before returning prepared JSON bytes.++- Bounded `wf inspect` commands and typed inspection errors; shared workflow rendering and paged compatibility reads.+++- Export the authoritative command grammar, classifier, runner, and invocation fields for alternative front ends.+++- `pm show/list` accepts reaction-state descriptors with explicitly unavailable provenance. Capture and `legacy-positional-v1` remain legacy positional features scheduled for retirement before 1.0.+- Workflow list/show JSON includes `status_class`; step rows include `recorded_at`. Show uses owner-table creation-key ordering for children and awakeables. Shard status includes database `observed_at`, `heartbeat_at`, `updated_at` and `lease_age_seconds`.++### Bug Fixes++- Treat `pgmq dlq read` as a mutation because it reserves messages for 30 seconds. It now previews by default and requires `--force` to read; the HTTP surface uses POST with explicit confirmation.+++- Default workflow inspection consumes primary-key `wf-key2` pages; old default tokens fail closed and the obsolete exceptional-key refusal is removed. Filtered and creation-order reads retain host deadlines.+ ## 0.21.0.0 — 2026-10-14 ### Bug Fixes
keiro-ops.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: keiro-ops-version: 0.21.0.0+version: 0.22.0.0 synopsis: Operational command-line interface for Keiro deployments description: A standalone database-only console and embeddable command tree for inspecting@@ -49,12 +49,14 @@ hs-source-dirs: src exposed-modules: Keiro.Ops+ Keiro.Ops.Aggregate Keiro.Ops.Embed Keiro.Ops.Env Keiro.Ops.Inbox Keiro.Ops.Outbox Keiro.Ops.Parse Keiro.Ops.Pgmq+ Keiro.Ops.ProcessManager Keiro.Ops.Projection Keiro.Ops.Rebuild Keiro.Ops.Render@@ -64,6 +66,7 @@ Keiro.Ops.Stream Keiro.Ops.Timer Keiro.Ops.Workflow+ Keiro.Ops.WorkflowHeaderCache build-depends: aeson >=2.2.2 && <2.3,@@ -73,10 +76,11 @@ effectful >=2.6 && <2.8, effectful-core >=2.6 && <2.7 || >=2.7.1.1 && <2.8, hasql >=1.10 && <1.11,- keiro ^>=0.21.0.0,- keiro-migrations ^>=0.21.0.0,- keiro-pgmq ^>=0.21.0.0,- kiroku-store ^>=0.11.0.0,+ hasql-pool >=1.4 && <1.5,+ keiro ^>=0.22.0.0,+ keiro-migrations ^>=0.22.0.0,+ keiro-pgmq ^>=0.22.0.0,+ kiroku-store ^>=0.12.0.0, optparse-applicative >=0.18 && <0.20, text >=2.1 && <2.2, time >=1.12 && <1.15,@@ -106,6 +110,7 @@ -rtsopts -with-rtsopts=-N + other-modules: ReactionInspectionSpec build-tool-depends: keiro-ops:keiro-ops build-depends: aeson >=2.2 && <2.3,@@ -117,13 +122,14 @@ hasql >=1.10 && <1.11, hasql-transaction >=1.1 && <1.3, hspec >=2.11,- keiro ^>=0.21.0.0,+ keiki >=0.9.1 && <0.10,+ keiro ^>=0.22.0.0, keiro-ops,- keiro-pgmq ^>=0.21.0.0,- keiro-test-support ^>=0.21.0.0,- kiroku-store ^>=0.11.0.0,+ keiro-pgmq ^>=0.22.0.0,+ keiro-test-support ^>=0.22.0.0,+ kiroku-store ^>=0.12.0.0, optparse-applicative >=0.18 && <0.20,- pgmq-migration >=0.6.1.1 && <0.7,+ pgmq-migration >=0.7.0.0 && <0.8, process >=1.6 && <1.7, text >=2.1 && <2.2, time >=1.12 && <1.15,
src/Keiro/Ops.hs view
@@ -2,26 +2,39 @@ ( main, mainWithHooks, AppHooks (..),+ AggregateInspectors,+ ProcessManagerInspectors, OpsAuditConfig (..), emptyAppHooks,- OpsInvocation,+ OpsInvocation (..),+ Command (..), opsCommandTree,+ commandParser,+ validateCommand,+ isMutation,+ runCommand,+ runCommandWithHeaderCache,+ runCommandWithReadEncoding, runOpsInvocation, ) where import Control.Exception (SomeException, displayException, fromException, try)+import Data.Aeson.Encoding (Encoding)+import Data.ByteString (ByteString) import Data.Foldable (traverse_) import Data.Maybe (isJust) import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO import Hasql.Connection.Settings qualified as Settings import Keiro.Migrations.SchemaCheck (renderSchemaDrift, verifyExpectedSchema)+import Keiro.Ops.Aggregate qualified as Aggregate import Keiro.Ops.Embed import Keiro.Ops.Env import Keiro.Ops.Inbox qualified as Inbox import Keiro.Ops.Outbox qualified as Outbox import Keiro.Ops.Pgmq qualified as Pgmq+import Keiro.Ops.ProcessManager qualified as ProcessManager import Keiro.Ops.Projection qualified as Projection import Keiro.Ops.Rebuild qualified as Rebuild import Keiro.Ops.Render@@ -31,6 +44,7 @@ import Keiro.Ops.Stream qualified as Stream import Keiro.Ops.Timer qualified as Timer import Keiro.Ops.Workflow qualified as Workflow+import Keiro.Ops.WorkflowHeaderCache (WorkflowHeaderCache, runHeaderCached) import Kiroku.Store.Connection (defaultConnectionSettings, withStore) import Options.Applicative import System.Exit qualified as Exit@@ -60,8 +74,12 @@ opsCommand :: !Command } +-- | Programmatic front-end boundary. Alternative front ends must verify the+-- live schema, use JSON output mode, and respect 'isMutation'. data Command- = Workflow Workflow.Command+ = ProcessManager ProcessManager.Command+ | Aggregate Aggregate.Command+ | Workflow Workflow.Command | Timer Timer.Command | Outbox Outbox.Command | Inbox Inbox.Command@@ -85,6 +103,7 @@ invocationParser :: AppHooks -> Parser OpsInvocation invocationParser hooks = OpsInvocation <$> globalOptionsParser <*> commandParser hooks +-- | The authoritative command grammar, including only mounted hooks. commandParser :: AppHooks -> Parser Command commandParser hooks = hsubparser@@ -123,8 +142,17 @@ (info (Stream <$> Stream.commandParser) (progDesc "Inspect and operate Kiroku streams")) <> replayAuditCommand <> rebuildCommand+ <> aggregateCommand+ <> processManagerCommand ) where+ processManagerCommand = case hooks.processManagers of+ Nothing -> mempty+ Just _ -> command "pm" (info (ProcessManager <$> ProcessManager.commandParser) (progDesc "Inspect mounted process managers"))+ aggregateCommand =+ case hooks.aggregates of+ Nothing -> mempty+ Just _ -> command "aggregate" (info (Aggregate <$> Aggregate.commandParser) (progDesc "Inspect mounted aggregates")) replayAuditCommand = case hooks.replayAudit of Nothing -> mempty@@ -165,8 +193,20 @@ } runCommand hooks env opsCommand >>= finishOutcome env +-- | Validate supported command arguments before admission or schema work.+validateCommand :: Command -> Either (Text.Text, Text.Text) ()+validateCommand (ProcessManager command) = ProcessManager.validateCommand command+validateCommand (Aggregate command) = Aggregate.validateCommand command+validateCommand (Workflow command) = Workflow.validateCommand command+validateCommand (Timer command) = Timer.validateCommand command+validateCommand (Projection command) = Projection.validateCommand command+validateCommand _ = Right ()++-- | Authoritative read/write classification for every front end. isMutation :: Command -> Bool isMutation = \case+ ProcessManager command -> ProcessManager.isMutation command+ Aggregate command -> Aggregate.isMutation command Workflow workflowCommand -> Workflow.isMutation workflowCommand Timer timerCommand -> Timer.isMutation timerCommand Outbox outboxCommand -> Outbox.isMutation outboxCommand@@ -179,8 +219,35 @@ ReplayAudit _ -> False Rebuild rebuildCommand -> Rebuild.isMutation rebuildCommand +-- | Reuse only pure header presentation after an exact fresh database check.+-- The optional bytes encode this outcome's successful read result. Other+-- commands use the ordinary authoritative runner without a cache lookup.+runCommandWithHeaderCache :: WorkflowHeaderCache -> AppHooks -> OpsEnv -> Command -> IO (OpsOutcome, Maybe ByteString)+runCommandWithHeaderCache cache _ env (Workflow (Workflow.Inspect (Workflow.InspectHeader ref))) = runHeaderCached cache env ref+runCommandWithHeaderCache _ hooks env command = do+ outcome <- runCommand hooks env command+ pure (outcome, Nothing)++-- | Prepare supported direct encodings on demand using the host's bounded+-- encoder. Schema verification and admission remain the front end's job.+-- The callback must return the supplied encoding's exact bytes or refuse size.+-- Existing command/header behavior is delegated without changing its cache.+runCommandWithReadEncoding :: (Encoding -> IO (Maybe ByteString)) -> WorkflowHeaderCache -> AppHooks -> OpsEnv -> Command -> IO (OpsOutcome, Maybe ByteString)+runCommandWithReadEncoding encode cache hooks env command = case command of+ ProcessManager ProcessManager.Types | Just catalog <- hooks.processManagers -> do+ let (result, encoding) = ProcessManager.prepareTypes catalog+ encoded <- encode encoding+ pure $ case encoded of+ Nothing -> (InspectionFailed "inspection_result_too_large" "encoded result exceeds the host limit; narrow the read", Nothing)+ Just body -> (Succeeded result, Just body)+ _ -> runCommandWithHeaderCache cache hooks env command++-- | Execute a parsed command after schema verification. Front ends must use+-- JSON mode and enable force only after explicit mutation confirmation. runCommand :: AppHooks -> OpsEnv -> Command -> IO OpsOutcome runCommand hooks env = \case+ ProcessManager command -> maybe (pure (Failed "process-manager inspection hook is not mounted")) (\catalog -> ProcessManager.runCommand catalog env command) hooks.processManagers+ Aggregate command -> maybe (pure (Failed "aggregate inspection hook is not mounted")) (\catalog -> Aggregate.runCommand env catalog command) hooks.aggregates Workflow workflowCommand -> Workflow.runCommandWithResume hooks.workflowResume env workflowCommand Timer timerCommand -> Timer.runCommandWithFire hooks.timerFire env timerCommand Outbox outboxCommand -> Outbox.runCommand env outboxCommand@@ -210,6 +277,7 @@ Text.IO.hPutStrLn stderr ("preview only; re-run with --force: " <> reinvocation) pure (Exit.ExitFailure 1) Failed message -> operationalFailure message+ InspectionFailed code message -> operationalFailure (code <> ": " <> message) operationalFailure :: Text.Text -> IO Exit.ExitCode operationalFailure message = do
+ src/Keiro/Ops/Aggregate.hs view
@@ -0,0 +1,121 @@+-- | Read-only adapters over the application's aggregate catalog.+module Keiro.Ops.Aggregate (Command (..), ShowOptions (..), ListOptions (..), commandParser, validateCommand, isMutation, runCommand) where++import Data.Aeson (object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Effectful (Eff, IOE)+import Effectful.Error.Static (Error)+import Keiro.Inspection.Aggregate qualified as Aggregate+import Keiro.Inspection.Cursor (InspectionCursor, decodeCursor)+import Keiro.Ops.Env (OpsEnv (..))+import Keiro.Ops.Parse (cursorReader, positiveIntReader)+import Keiro.Ops.Render+import Kiroku.Store.Effect (Store, runStoreIO)+import Kiroku.Store.Error (StoreError)+import Kiroku.Store.Types (StreamName (..), StreamVersion (..))+import Options.Applicative hiding (action, command, info, value)+import Options.Applicative qualified as Opt++data ShowOptions = ShowOptions {name :: !Text, aggregateId :: !Text}+ deriving stock (Eq, Show)++data ListOptions = ListOptions {name :: !Text, limit :: !Int, after :: !(Maybe InspectionCursor)}+ deriving stock (Eq, Show)++data Command = Types | Show !ShowOptions | Snapshot !ShowOptions | List !ListOptions+ deriving stock (Eq, Show)++commandParser :: Parser Command+commandParser =+ hsubparser+ ( Opt.command "types" (Opt.info (pure Types) (progDesc "List mounted aggregate kinds"))+ <> Opt.command "show" (Opt.info (Show <$> showOptions) (progDesc "Inspect folded aggregate state"))+ <> Opt.command "snapshot" (Opt.info (Snapshot <$> showOptions) (progDesc "Inspect aggregate snapshot health"))+ <> Opt.command "list" (Opt.info (List <$> listOptions) (progDesc "Read one aggregate page"))+ )+ where+ textOption key label = Text.pack <$> strOption (long key <> metavar label)+ nameOption = textOption "name" "NAME"+ showOptions = ShowOptions <$> nameOption <*> textOption "id" "ID"+ listOptions = ListOptions <$> nameOption <*> option positiveIntReader (long "limit" <> Opt.value 50 <> metavar "N") <*> optional (option cursorReader (long "after" <> metavar "CURSOR"))++isMutation :: Command -> Bool+isMutation = const False++-- | Reject invalid input before a transport borrows a schema-check connection.+-- The owning page read additionally checks the catalog's exact category.+validateCommand :: Command -> Either (Text, Text) ()+validateCommand = \case+ Types -> Right ()+ Show options -> validateId options+ Snapshot options -> validateId options+ List options+ | options.limit < 1 || options.limit > Aggregate.maxAggregatePageSize -> Left ("invalid_page_size", "page size must be 1..500")+ | otherwise -> case options.after of+ Nothing -> Right ()+ Just cursor -> case decodeCursor Aggregate.aggregateListCursorKind cursor of+ Right (name, category, after :: Text)+ | name == options.name && Text.isPrefixOf (category <> "-") after -> Right ()+ _ -> Left ("invalid_cursor", "invalid cursor or cursor belongs to another aggregate inspector")+ where+ validateId options+ | Text.null (Text.strip options.aggregateId) = Left ("invalid_aggregate_id", "aggregate id must not be blank")+ | otherwise = Right ()++runCommand :: OpsEnv -> Aggregate.AggregateInspectors -> Command -> IO OpsOutcome+runCommand env catalog command = case command of+ Types ->+ pure+ ( Succeeded+ ( OpsResult+ ["name", "category", "snapshots_configured", "snapshot_policy"]+ [[summary.name, summary.category, if summary.snapshotsConfigured then "true" else "false", summary.snapshotPolicy] | summary <- Aggregate.listAggregateTypes catalog]+ (object ["items" .= map Aggregate.aggregateTypeJson (Aggregate.listAggregateTypes catalog)])+ )+ )+ Show options -> case Map.lookup options.name catalog of+ Nothing -> pure (Failed ("unknown aggregate kind: " <> options.name))+ Just (Aggregate.SomeAggregateInspector inspector) ->+ runAction env (Aggregate.inspectAggregate inspector options.aggregateId) $ \view ->+ OpsResult+ ["name", "aggregate_id", "stream_version", "state"]+ [[view.name, view.aggregateId, versionText view.streamVersion, maybe "" id view.stateLabel]]+ (Aggregate.aggregateViewJson view)+ Snapshot options -> case Map.lookup options.name catalog of+ Nothing -> pure (Failed ("unknown aggregate kind: " <> options.name))+ Just (Aggregate.SomeAggregateInspector inspector) ->+ runAction env (Aggregate.snapshotStatusFor inspector options.aggregateId) $ \(StreamName streamName, StreamVersion version, status) ->+ let value = case Aggregate.snapshotStatusJson status of+ Aeson.Object fields -> Aeson.Object (fields <> KeyMap.fromList [("name", Aeson.toJSON options.name), ("aggregate_id", Aeson.toJSON options.aggregateId), ("stream_name", Aeson.toJSON streamName), ("stream_version", Aeson.toJSON version)])+ other -> other+ in OpsResult ["name", "aggregate_id"] [[options.name, options.aggregateId]] value+ List options -> case Map.lookup options.name catalog of+ Nothing -> pure (Failed ("unknown aggregate kind: " <> options.name))+ Just (Aggregate.SomeAggregateInspector inspector) ->+ runAction env (Aggregate.listAggregates inspector (Aggregate.AggregateListRequest options.limit options.after)) $ \page ->+ OpsResult+ ["aggregate_id", "stream_name", "stream_version"]+ [[item.aggregateId, nameText item.streamName, versionText item.streamVersion] | item <- page.items]+ (Aggregate.aggregateListPageJson page)++runAction :: OpsEnv -> Eff '[Store, Error StoreError, IOE] (Either Aggregate.AggregateInspectionError a) -> (a -> OpsResult) -> IO OpsOutcome+runAction env action render = do+ result <- runStoreIO env.store action+ pure $ case result of+ Left err -> Failed (Text.pack (show err))+ Right (Left err) -> case err of+ Aggregate.InvalidPageSize _ -> InspectionFailed "invalid_page_size" (Aggregate.inspectionErrorText err)+ Aggregate.InvalidCursor _ -> InspectionFailed "invalid_cursor" (Aggregate.inspectionErrorText err)+ Aggregate.ResultTooLarge -> InspectionFailed "inspection_result_too_large" (Aggregate.inspectionErrorText err)+ _ -> Failed (Aggregate.inspectionErrorText err)+ Right (Right value) -> Succeeded (render value)++versionText :: StreamVersion -> Text+versionText (StreamVersion version) = Text.pack (show version)++nameText :: StreamName -> Text+nameText (StreamName name) = name
src/Keiro/Ops/Embed.hs view
@@ -6,20 +6,26 @@ module Keiro.Ops.Embed ( AppHooks (..), OpsAuditConfig (..),+ AggregateInspectors,+ ProcessManagerInspectors, emptyAppHooks, ) where +import Keiro.Inspection.Aggregate (AggregateInspectors) import Keiro.Ops.ReplayAudit (OpsAuditConfig (..)) import Keiro.Ops.Timer (TimerFire) import Keiro.Ops.Workflow (ResumeHook)+import Keiro.ProcessManager.Inspect (ProcessManagerInspectors) import Keiro.Projection.Catalog.Operations (ProjectionCatalogOperations) data AppHooks = AppHooks { workflowResume :: !(Maybe ResumeHook), timerFire :: !(Maybe TimerFire), replayAudit :: !(Maybe OpsAuditConfig),- projectionCatalog :: !(Maybe ProjectionCatalogOperations)+ projectionCatalog :: !(Maybe ProjectionCatalogOperations),+ aggregates :: !(Maybe AggregateInspectors),+ processManagers :: !(Maybe ProcessManagerInspectors) } emptyAppHooks :: AppHooks@@ -28,5 +34,7 @@ { workflowResume = Nothing, timerFire = Nothing, replayAudit = Nothing,- projectionCatalog = Nothing+ projectionCatalog = Nothing,+ aggregates = Nothing,+ processManagers = Nothing }
src/Keiro/Ops/Parse.hs view
@@ -5,13 +5,20 @@ positiveIntReader, nonNegativeReader, nonNegativeIntReader,+ cursorReader, ) where import Data.Char (toLower)+import Data.Text qualified as Text import Data.Time (NominalDiffTime)-import Options.Applicative (ReadM, eitherReader)+import Keiro.Inspection.Cursor (InspectionCursor (..))+import Options.Applicative (ReadM, eitherReader, str) import Text.Read qualified as Read++-- | The owning read validates the opaque token's kind, owner, and payload.+cursorReader :: ReadM InspectionCursor+cursorReader = InspectionCursor . Text.pack <$> str durationReader :: ReadM NominalDiffTime durationReader = eitherReader parseDuration
src/Keiro/Ops/Pgmq.hs view
@@ -46,7 +46,7 @@ dlqParser :: Parser DlqCommand dlqParser = hsubparser- ( command "read" (info readParser (progDesc "Read and decode visible DLQ entries"))+ ( command "read" (info readParser (progDesc "Preview or reserve and decode visible DLQ entries for 30 seconds")) <> command "redrive" (info redriveParser (progDesc "Preview or move DLQ entries back to the main queue")) <> command "archive" (info archiveParser (progDesc "Preview or archive DLQ entries for retention")) <> command "purge" (info purgeParser (progDesc "Preview or permanently purge a DLQ"))@@ -75,14 +75,16 @@ isMutation :: Command -> Bool isMutation (Dlq dlqCommand) = case dlqCommand of- Read {} -> False+ Read {} -> True Redrive {} -> True Archive {} -> True Purge {} -> True runCommand :: OpsEnv -> Command -> IO OpsOutcome runCommand env (Dlq dlqCommand) = case dlqCommand of- Read queue limit -> handlePgmq (runJobEff (pgmqRuntime env) (readDlq (rawJob queue) (fromIntegral limit))) (Succeeded . dlqListResult queue)+ Read queue limit+ | env.force -> handlePgmq (runJobEff (pgmqRuntime env) (readDlq (rawJob queue) (fromIntegral limit))) (Succeeded . dlqListResult queue)+ | otherwise -> previewFromDepth env "read" queue (Just limit) ["pgmq", "dlq", "read", "--queue", queue, "--limit", showText limit] Redrive queue limit -> runRedrive env queue limit Archive queue entry limit -> runArchive env queue entry limit Purge queue -> runPurge env queue
+ src/Keiro/Ops/ProcessManager.hs view
@@ -0,0 +1,136 @@+-- | Thin read-only adapters over application-owned manager inspectors.+module Keiro.Ops.ProcessManager (Command (..), ShowOptions (..), ListOptions (..), commandParser, validateCommand, isMutation, runCommand, prepareTypes) where++import Data.Aeson qualified as Aeson+import Data.Aeson.Encoding (Encoding)+import Data.Aeson.Encoding qualified as Encoding+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Map.Lazy qualified as LazyMap+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Keiro.Inspection.Cursor (InspectionCursor, decodeCursor)+import Keiro.Ops.Env (OpsEnv (..))+import Keiro.Ops.Parse (cursorReader, positiveIntReader)+import Keiro.Ops.Render+import Keiro.ProcessManager.Inspect qualified as PM+import Kiroku.Store.Effect (runStoreIO)+import Options.Applicative hiding (command, info, value)+import Options.Applicative qualified as Opt++data ShowOptions = ShowOptions {name :: !Text, correlationId :: !Text, timerLimit :: !Int}+ deriving stock (Eq, Show)++data ListOptions = ListOptions {name :: !Text, after :: !(Maybe InspectionCursor), limit :: !Int}+ deriving stock (Eq, Show)++data Command = Types | Show !ShowOptions | List !ListOptions deriving stock (Eq, Show)++commandParser :: Parser Command+commandParser =+ hsubparser+ ( Opt.command "types" (Opt.info (pure Types) (progDesc "List mounted process managers"))+ <> Opt.command+ "show"+ ( Opt.info+ ( Show+ <$> ( ShowOptions+ <$> name+ <*> textOption "correlation" "ID"+ <*> option positiveIntReader (long "timer-limit" <> Opt.value 20 <> metavar "N")+ )+ )+ (progDesc "Observe persisted manager state and intended dispatch witnesses")+ )+ <> Opt.command+ "list"+ ( Opt.info+ ( List+ <$> ( ListOptions+ <$> name+ <*> optional (option cursorReader (long "after" <> metavar "CURSOR"))+ <*> option positiveIntReader (long "limit" <> Opt.value 50 <> metavar "N")+ )+ )+ (progDesc "Read one page of manager journal streams")+ )+ )+ where+ name = textOption "name" "NAME"+ textOption key label = Text.pack <$> strOption (long key <> metavar label)++isMutation :: Command -> Bool+isMutation = const False++validateCommand :: Command -> Either (Text, Text) ()+validateCommand Types = Right ()+validateCommand (Show options)+ | Text.null (Text.strip options.correlationId) = Left ("invalid_correlation_id", "correlation id must not be blank")+ | options.timerLimit < 1 || options.timerLimit > 500 = Left ("invalid_page_size", "timer page size must be 1..500")+ | otherwise = Right ()+validateCommand (List options)+ | options.limit < 1 || options.limit > 500 = Left ("invalid_page_size", "page size must be 1..500")+ | otherwise = case options.after of+ Nothing -> Right ()+ Just cursor -> case decodeCursor PM.instanceListCursorKind cursor of+ Right (owner, category, after :: Text) | owner == options.name && Text.isPrefixOf (category <> "-") after -> Right ()+ _ -> Left ("invalid_cursor", "cursor belongs to another manager inspector or is malformed")++runCommand :: PM.ProcessManagerInspectors -> OpsEnv -> Command -> IO OpsOutcome+runCommand catalog env command = case validateCommand command of+ Left (code, detail) -> pure (InspectionFailed code detail)+ Right () -> case command of+ Types -> pure (Succeeded (fst (prepareTypes catalog)))+ Show options -> case Map.lookup options.name catalog of+ Nothing -> unknown options.name+ Just (PM.SomeProcessManagerInspector inspector) -> do+ result <- runStoreIO env.store (PM.inspectProcessManagerInstance inspector (PM.InstanceViewRequest options.timerLimit) options.correlationId)+ pure $ case result of+ Left err -> Failed (Text.pack (show err))+ Right (Left err) -> Failed (Text.pack (show err))+ Right (Right value) ->+ Succeeded+ ( OpsResult+ ["name", "correlation_id", "state"]+ (maybe [] (\view -> [[view.name, view.correlationId, maybe "" id view.stateLabel]]) value)+ (maybe Aeson.Null PM.processManagerViewJson value)+ )+ List options -> case Map.lookup options.name catalog of+ Nothing -> unknown options.name+ Just (PM.SomeProcessManagerInspector inspector) -> do+ result <- runStoreIO env.store (PM.listProcessManagerInstances inspector (PM.InstanceListRequest options.limit options.after))+ pure $ case result of+ Left err -> Failed (Text.pack (show err))+ Right (Left err) -> uncurry InspectionFailed (listError err)+ Right (Right page) ->+ Succeeded+ ( OpsResult+ ["correlation_id", "stream_version"]+ [[item.correlationId, Text.pack (show item.streamVersion)] | item <- page.items]+ (PM.instancePageJson page)+ )+ where+ unknown owner = pure (Failed ("unknown process manager: " <> owner))+ listError err =+ ( case err of+ PM.InvalidInstancePageSize _ -> "invalid_page_size"+ PM.InvalidInstanceCursor _ -> "invalid_cursor"+ PM.InstanceResultTooLarge -> "inspection_result_too_large",+ Text.pack (show err)+ )++-- | Canonical presentation and an equivalent direct encoding from one traversal.+-- The host must consume the encoding inside its own byte/deadline budget.+-- Neither representation is retained between requests.+prepareTypes :: PM.ProcessManagerInspectors -> (OpsResult, Encoding)+prepareTypes catalog =+ let summaries = PM.listProcessManagerTypes catalog+ -- Defer the canonical array until a caller actually consumes JSON. HTTP+ -- can consume the direct encoding while retaining the same OpsResult API.+ value = Aeson.Object (KeyMap.fromMap (LazyMap.singleton "items" (Aeson.toJSON summaries)))+ in ( OpsResult+ ["name", "category", "snapshots_configured", "snapshot_policy", "timers_configured"]+ [[summary.name, summary.category, if summary.snapshotsConfigured then "true" else "false", summary.snapshotPolicy, maybe "unknown" (\configured -> if configured then "true" else "false") summary.timersConfigured] | summary <- summaries]+ value,+ Encoding.pairs (Encoding.pair "items" (Aeson.toEncoding summaries))+ )
src/Keiro/Ops/Projection.hs view
@@ -4,6 +4,9 @@ -- with ADR 28. module Keiro.Ops.Projection ( Command (..),+ ProjectionStatusOptions (..),+ projectionGroupStatusJson,+ validateCommand, commandParser, isMutation, runCommand,@@ -18,10 +21,14 @@ import Data.Vector qualified as Vector import Effectful (Eff, IOE) import Effectful.Error.Static (Error)+import Keiro.Inspection.Cursor (InspectionCursor (..)) import Keiro.Ops.Env (OpsEnv (..), OutputMode (..))+import Keiro.Ops.Parse (cursorReader, positiveIntReader) import Keiro.Ops.Render import Keiro.Projection (countAsyncProjectionDedupForBefore, pruneAsyncProjectionDedupForBefore)+import Keiro.Projection.Catalog (projectionRevisionIdText, rebuildGroupIdText) import Keiro.ReadModel (storeHeadPosition)+import Keiro.ReadModel.Rebuild import Kiroku.Store.Effect (Store, runStoreIO) import Kiroku.Store.Error (StoreError) import Kiroku.Store.Subscription@@ -31,18 +38,23 @@ subscriptionCheckpointInventory, ) import Kiroku.Store.Types (GlobalPosition (..))-import Options.Applicative hiding (action, value)+import Options.Applicative hiding (action) import Prelude +data ProjectionStatusOptions = ProjectionStatusOptions {limit :: !Int, after :: !(Maybe InspectionCursor)}+ deriving stock (Eq, Show)+ data Command- = Position !Text+ = Status !ProjectionStatusOptions+ | Position !Text | PruneDedup !Text !UTCTime deriving stock (Eq, Show) commandParser :: Parser Command commandParser = hsubparser- ( command "position" (info positionParser (progDesc "Show one subscription's durable member checkpoints and floor"))+ ( command "status" (info (Status <$> (ProjectionStatusOptions <$> option positiveIntReader (long "limit" <> value 100 <> metavar "N") <*> optional (option cursorReader (long "after" <> metavar "CURSOR")))) (progDesc "Observe one bounded page of projection-group status"))+ <> command "position" (info positionParser (progDesc "Show one subscription's durable member checkpoints and floor")) <> command "prune-dedup" (info pruneParser (progDesc "Preview or prune one projection's old dedup rows")) ) where@@ -62,13 +74,49 @@ Right (parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%S%QZ" raw) +statusInput :: ProjectionStatusOptions -> ProjectionStatusPageRequest+statusInput options = ProjectionStatusPageRequest options.limit options.after++validateCommand :: Command -> Either (Text, Text) ()+validateCommand (Status options) = either (Left . statusError) (const (Right ())) (validateProjectionStatusRequest (statusInput options))+validateCommand _ = Right ()++statusError :: ProjectionStatusReadError -> (Text, Text)+statusError err =+ ( case err of+ InvalidProjectionStatusPageSize _ -> "invalid_page_size"+ InvalidProjectionStatusCursor _ -> "invalid_cursor"+ ProjectionStatusResultTooLarge -> "inspection_result_too_large",+ Text.pack (show err)+ )+ isMutation :: Command -> Bool isMutation = \case+ Status {} -> False Position {} -> False PruneDedup {} -> True runCommand :: OpsEnv -> Command -> IO OpsOutcome runCommand env = \case+ Status options -> runAction env action $ \case+ Left err -> uncurry InspectionFailed (statusError err)+ Right (page, head) ->+ Succeeded+ ( OpsResult+ ["group_id", "lifecycle_phase", "reads_allowed", "serving_position_basis"]+ [[rebuildGroupIdText row.groupId, row.lifecyclePhase, Text.pack (show row.readsAllowed), basisText row.servingPositionBasis] | row <- page.items]+ ( object+ ( ["observed_at" .= page.observedAt, "visible_store_head" .= positionInt head, "items" .= map projectionGroupStatusJson page.items]+ <> maybe [] (\cursor -> ["next_cursor" .= cursor.cursorText]) page.next+ )+ )+ )+ where+ action = do+ page <- listProjectionGroupStatusesPage (statusInput options)+ case page of+ Left err -> pure (Left err)+ Right value -> Right . (value,) <$> storeHeadPosition Position subscription -> runPosition env subscription PruneDedup projection before | env.force ->@@ -79,6 +127,33 @@ PreviewRequired (pruneResult True projection before affected) (forceInvocation env ["projection", "prune-dedup", "--projection", projection, "--before", utcText before])++-- | The frozen v1 owner relation, using ADR-28 position vocabulary.+projectionGroupStatusJson :: ProjectionGroupStatusV1 -> Value+projectionGroupStatusJson row =+ object+ [ "group_id" .= rebuildGroupIdText row.groupId,+ "lifecycle_phase" .= row.lifecyclePhase,+ "reads_allowed" .= row.readsAllowed,+ "writes_allowed" .= row.writesAllowed,+ "serving_revision_id" .= fmap projectionRevisionIdText row.servingRevisionId,+ "serving_epoch" .= row.servingEpoch,+ "serving_position_basis" .= basisText row.servingPositionBasis,+ "serving_applied_position" .= fmap positionInt row.servingAppliedPosition,+ "active_run_id" .= fmap rebuildRunIdText row.activeRunId,+ "candidate_revision_id" .= fmap projectionRevisionIdText row.candidateRevisionId,+ "candidate_rebuild_position" .= fmap positionInt row.candidateRebuildPosition,+ "candidate_rebuild_head" .= fmap positionInt row.candidateRebuildHead,+ "query_models" .= row.queryModels,+ "rebuild_started_at" .= row.rebuildStartedAt,+ "last_promoted_at" .= row.lastPromotedAt,+ "failed_at" .= row.failedAt,+ "failure_code" .= row.failureCode,+ "failure_detail" .= row.failureDetail+ ]++basisText :: ServingPositionBasis -> Text+basisText = \case ServingPositionAppend -> "append"; ServingPositionCheckpoint -> "checkpoint"; ServingPositionUnmanaged -> "unmanaged" runPosition :: OpsEnv -> Text -> IO OpsOutcome runPosition env subscription =
src/Keiro/Ops/Render.hs view
@@ -34,6 +34,7 @@ | SucceededWithExit !OpsResult !ExitCode | PreviewRequired !OpsResult !Text | Failed !Text+ | InspectionFailed !Text !Text deriving stock (Eq, Show) emptyResult :: OpsResult
src/Keiro/Ops/Shard.hs view
@@ -10,11 +10,12 @@ ) where -import Data.Aeson (object, (.=))+import Data.Aeson (Value, object, (.=))+import Data.Map.Strict qualified as Map import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as Text-import Data.Time (UTCTime, getCurrentTime)+import Data.Time (UTCTime) import Data.UUID (UUID) import Data.UUID qualified as UUID import Effectful (Eff, IOE, (:>))@@ -28,14 +29,16 @@ import Options.Applicative hiding (action, value) data Command- = Status !Text+ = List+ | Status !Text | Relinquish !Text !WorkerId deriving stock (Eq, Show) commandParser :: Parser Command commandParser = hsubparser- ( command "status" (info statusParser (progDesc "Show shard counts and ownership"))+ ( command "list" (info (pure List) (progDesc "List sharded subscriptions and live ownership counts"))+ <> command "status" (info statusParser (progDesc "Show shard counts and ownership")) <> command "relinquish" (info relinquishParser (progDesc "Preview or release every bucket owned by one worker")) ) where@@ -50,22 +53,16 @@ isMutation :: Command -> Bool isMutation = \case+ List -> False Status {} -> False Relinquish {} -> True runCommand :: OpsEnv -> Command -> IO OpsOutcome runCommand env = \case- Status name -> do- now <- getCurrentTime- runAction env (statusAction name) (Succeeded . uncurry (statusResult now name))+ List -> runAction env listShardedSubscriptions (Succeeded . listResult)+ Status name -> runAction env (shardOwnershipView (SubscriptionName name)) (Succeeded . statusResult) Relinquish name worker -> runRelinquish env name worker -statusAction :: (Store :> es) => Text -> Eff es ([(Int, Maybe WorkerId, Maybe UTCTime)], [(Int, Int)])-statusAction name = do- ownership <- ownershipSnapshotFor (SubscriptionName name)- counts <- shardCountSnapshot (SubscriptionName name)- pure (ownership, counts)- runRelinquish :: OpsEnv -> Text -> WorkerId -> IO OpsOutcome runRelinquish env name worker | not env.force =@@ -94,38 +91,66 @@ result <- runStoreIO env.store action pure $ either (Failed . Text.pack . show) onSuccess result -statusResult :: UTCTime -> Text -> [(Int, Maybe WorkerId, Maybe UTCTime)] -> [(Int, Int)] -> OpsResult-statusResult now name ownership counts =+statusResult :: ShardOwnershipView -> OpsResult+statusResult view = OpsResult { headers = ["subscription", "bucket", "owner", "lease_expires_at", "lease_state", "configured_shards"],- rows = map rowText ownership,- jsonValue = object ["subscription" .= name, "shard_counts" .= map countJson counts, "ownership" .= map ownershipJson ownership]+ rows = map rowText view.buckets,+ jsonValue =+ object+ [ "subscription" .= name,+ "observed_at" .= view.observedAt,+ "shard_counts" .= map countJson counts,+ "ownership" .= map ownershipJson view.buckets+ ] } where+ SubscriptionName name = view.subscriptionName+ counts = Map.toAscList (Map.fromListWith (+) [(row.shardCount, 1 :: Int) | row <- view.buckets]) configured = Text.intercalate "," [showText shardCount <> " (" <> showText rowCount <> " rows)" | (shardCount, rowCount) <- counts]- rowText (bucket, owner, expiresAt) =+ rowText row = [ name,- showText bucket,- maybe "unowned" workerText owner,- maybe "" timeText expiresAt,- leaseState now owner expiresAt,+ showText row.bucket,+ maybe "unowned" workerText row.owner,+ maybe "" timeText row.leaseExpiresAt,+ leaseStateText (leaseStateAt view.observedAt row), configured ]- countJson (shardCount, rowCount) = object ["shard_count" .= shardCount, "rows" .= rowCount]- ownershipJson (bucket, owner, expiresAt) =+ ownershipJson row = object- [ "bucket" .= bucket,- "owner" .= fmap workerText owner,- "lease_expires_at" .= expiresAt,- "lease_state" .= leaseState now owner expiresAt+ [ "bucket" .= row.bucket,+ "owner" .= fmap workerText row.owner,+ "lease_expires_at" .= row.leaseExpiresAt,+ "lease_state" .= leaseStateText (leaseStateAt view.observedAt row),+ "heartbeat_at" .= row.heartbeatAt,+ "updated_at" .= row.updatedAt,+ "lease_age_seconds" .= fmap (\age -> realToFrac age :: Double) (leaseAgeAt view.observedAt row) ] -leaseState :: UTCTime -> Maybe WorkerId -> Maybe UTCTime -> Text-leaseState _ Nothing _ = "unowned"-leaseState now (Just _) (Just expiry)- | expiry < now = "expired"- | otherwise = "live"-leaseState _ (Just _) Nothing = "invalid"+countJson :: (Int, Int) -> Value+countJson (shardCount, rowCount) = object ["shard_count" .= shardCount, "rows" .= rowCount]++listResult :: ShardedSubscriptionList -> OpsResult+listResult view =+ OpsResult+ ["subscription", "buckets_total", "buckets_live", "live_owners"]+ [[nameText row.subscriptionName, showText row.bucketsTotal, showText row.bucketsLive, showText row.liveOwners] | row <- view.subscriptions]+ (object ["observed_at" .= view.observedAt, "items" .= map summaryJson view.subscriptions])+ where+ nameText (SubscriptionName name) = name+ summaryJson row =+ object+ [ "subscription" .= nameText row.subscriptionName,+ "shard_counts" .= map countJson row.shardCounts,+ "buckets_total" .= row.bucketsTotal,+ "buckets_live" .= row.bucketsLive,+ "buckets_expired" .= row.bucketsExpired,+ "buckets_unowned" .= row.bucketsUnowned,+ "live_owners" .= row.liveOwners+ ]++leaseStateText :: LeaseState -> Text+leaseStateText = \case LeaseLive -> "live"; LeaseExpired -> "expired"; LeaseUnowned -> "unowned"; LeaseInvalid -> "invalid" relinquishResult :: Bool -> Text -> WorkerId -> [Int] -> OpsResult relinquishResult preview name worker buckets =
src/Keiro/Ops/Snapshot.hs view
@@ -15,10 +15,12 @@ import Data.Aeson (Value, object, (.=)) import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString.Lazy qualified as LazyByteString import Data.Int (Int64) import Data.Text (Text) import Data.Text qualified as Text+import Data.Time (diffUTCTime) import Effectful (Eff, IOE, (:>)) import Effectful.Error.Static (Error) import Keiro.Ops.Env (OpsEnv (..), OutputMode (..))@@ -92,7 +94,7 @@ runCommand :: OpsEnv -> Command -> IO OpsOutcome runCommand env = \case- Show name -> runAction env (lookupByName name) (Succeeded . snapshotResult name)+ Show name -> runAction env (observationByName name) (Succeeded . snapshotObservedResult name) Delete name -> runDelete env name TruncationPreflight name before suppliedExpected -> runAction env (preflightFor name before suppliedExpected) (Succeeded . preflightResult)@@ -104,6 +106,11 @@ Nothing -> pure Nothing Just info -> lookupSnapshotRow info.id +observationByName :: (Store :> es) => Text -> Eff es (Maybe SnapshotObservation)+observationByName name = do+ stream <- getStream (StreamName name)+ traverse (observeSnapshotRow . (.id)) stream+ runDelete :: OpsEnv -> Text -> IO OpsOutcome runDelete env name | not env.force =@@ -193,6 +200,21 @@ rows = maybe [] (pure . snapshotRowText name) row, jsonValue = maybe Aeson.Null (snapshotJson name) row }++snapshotObservedResult :: Text -> Maybe SnapshotObservation -> OpsResult+snapshotObservedResult name observation =+ case observation of+ Just observed+ | Just row <- observed.row ->+ let age = realToFrac (diffUTCTime observed.observedAt row.updatedAt) :: Double+ original = snapshotResult name (Just row)+ fields = KeyMap.fromList [("observed_at", Aeson.toJSON observed.observedAt), ("age_seconds", Aeson.toJSON age)]+ in original+ { headers = original.headers <> ["observed_at", "age_seconds"],+ rows = map (<> [showText observed.observedAt, showText age]) original.rows,+ jsonValue = case original.jsonValue of Aeson.Object value -> Aeson.Object (value <> fields); value -> value+ }+ _ -> snapshotResult name Nothing snapshotRowText :: Text -> SnapshotRow -> [Text] snapshotRowText name row =
src/Keiro/Ops/Timer.hs view
@@ -2,6 +2,9 @@ ( Command (..), DrainOptions (..), StuckListOptions (..),+ PendingListOptions (..),+ pendingTimerItemJson,+ validateCommand, TimerFire, commandParser, commandParserWithDrain,@@ -20,8 +23,9 @@ import Data.UUID qualified as UUID import Effectful (Eff, IOE) import Effectful.Error.Static (Error)+import Keiro.Inspection.Cursor (InspectionCursor (..)) import Keiro.Ops.Env (OpsEnv (..), OutputMode (..))-import Keiro.Ops.Parse (durationReader, nonNegativeIntReader, positiveIntReader)+import Keiro.Ops.Parse (cursorReader, durationReader, nonNegativeIntReader, positiveIntReader) import Keiro.Ops.Render import Keiro.Timer import Kiroku.Store.Effect (Store, runStoreIO)@@ -36,6 +40,10 @@ } deriving stock (Eq, Show) +data PendingListOptions = PendingListOptions+ {manager :: !(Maybe Text), correlation :: !(Maybe Text), after :: !(Maybe InspectionCursor), limit :: !Int}+ deriving stock (Eq, Show)+ data DrainOptions = DrainOptions { limit :: !Int }@@ -45,6 +53,7 @@ data Command = StuckList !StuckListOptions+ | PendingList !PendingListOptions | Requeue !TimerId | Cancel !TimerId | DeadLetter !TimerId !Text@@ -57,13 +66,14 @@ commandParserWithDrain :: Bool -> Parser Command commandParserWithDrain includeDrain = hsubparser- ( command- "stuck"- ( info- stuckCommandParser- (progDesc "Find timers left firing by an interrupted worker")- )+ ( command "pending" (info pendingCommandParser (progDesc "Read a bounded page of scheduled and firing timers")) <> command+ "stuck"+ ( info+ stuckCommandParser+ (progDesc "Find timers left firing by an interrupted worker")+ )+ <> command "requeue" ( info (Requeue <$> timerIdArgument)@@ -97,6 +107,42 @@ ) | otherwise = mempty +pendingCommandParser :: Parser Command+pendingCommandParser =+ hsubparser+ ( command+ "list"+ ( info+ ( PendingList+ <$> ( PendingListOptions+ <$> optional (Text.pack <$> strOption (long "manager" <> metavar "NAME"))+ <*> optional (Text.pack <$> strOption (long "correlation" <> metavar "ID"))+ <*> optional (option cursorReader (long "after" <> metavar "CURSOR"))+ <*> option positiveIntReader (long "limit" <> metavar "N" <> Optparse.value 100)+ )+ )+ (progDesc "Observe pending timers without claiming them")+ )+ )++pendingInput :: PendingListOptions -> (PendingTimerFilter, PendingTimerPageRequest)+pendingInput options = (PendingTimerFilter options.manager options.correlation, PendingTimerPageRequest options.limit options.after)++validateCommand :: Command -> Either (Text, Text) ()+validateCommand (PendingList options) =+ let (timerFilter, request) = pendingInput options+ in either (Left . pendingError) (const (Right ())) (validatePendingTimerRequest timerFilter request)+validateCommand _ = Right ()++pendingError :: PendingTimerReadError -> (Text, Text)+pendingError err =+ ( case err of+ InvalidPendingTimerPageSize _ -> "invalid_page_size"+ InvalidPendingTimerCursor _ -> "invalid_cursor"+ PendingTimerResultTooLarge -> "inspection_result_too_large",+ Text.pack (show err)+ )+ stuckCommandParser :: Parser Command stuckCommandParser = hsubparser@@ -123,6 +169,7 @@ isMutation :: Command -> Bool isMutation = \case StuckList {} -> False+ PendingList {} -> False Requeue {} -> True Cancel {} -> True DeadLetter {} -> True@@ -134,10 +181,30 @@ runCommandWithFire :: Maybe TimerFire -> OpsEnv -> Command -> IO OpsOutcome runCommandWithFire timerFire env = \case StuckList options -> runStuckList env options+ PendingList options -> runPendingList env options Requeue timerId -> runMutation env RequeueOperation timerId Nothing Cancel timerId -> runMutation env CancelOperation timerId Nothing DeadLetter timerId reason -> runMutation env DeadLetterOperation timerId (Just reason) DrainOnce options -> runDrainOnce timerFire env options++runPendingList :: OpsEnv -> PendingListOptions -> IO OpsOutcome+runPendingList env options = do+ let (timerFilter, request) = pendingInput options+ runAction env (findPendingTimers timerFilter request) $ \case+ Left err -> uncurry InspectionFailed (pendingError err)+ Right page ->+ Succeeded+ ( OpsResult+ ["id", "manager", "correlation", "fire_at", "status", "attempts", "payload"]+ (map (timerRow . (.timer)) page.timers)+ ( object+ ( ["observed_at" .= page.observedAt, "items" .= map (pendingTimerItemJson page.observedAt) page.timers]+ <> maybe [] (\manager -> ["manager" .= manager]) options.manager+ <> maybe [] (\correlation -> ["correlation_id" .= correlation]) options.correlation+ <> maybe [] (\cursor -> ["next_cursor" .= cursor.cursorText]) page.next+ )+ )+ ) runDrainOnce :: Maybe TimerFire -> OpsEnv -> DrainOptions -> IO OpsOutcome runDrainOnce Nothing _ _ = pure (Failed "timer fire hook is not mounted")
src/Keiro/Ops/Workflow.hs view
@@ -1,6 +1,7 @@ module Keiro.Ops.Workflow ( AwakeableCommand (..), Command (..),+ InspectionCommand (..), GcOptions (..), InspectOptions (..), ListOptions (..),@@ -9,39 +10,37 @@ ResumeOptions (..), WorkflowRef (..), commandParser,+ validateCommand, commandParserWithResume, isMutation, runCommand, runCommandWithResume,+ headerOutcome, ) where import Data.Aeson (Value, object, (.=)) import Data.Aeson qualified as Aeson-import Data.Aeson.Types qualified as AesonTypes-import Data.Int (Int64) import Data.List.NonEmpty qualified as NonEmpty-import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map-import Data.Maybe (catMaybes) import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as Text import Data.Text.Encoding qualified as Text.Encoding-import Data.Time (NominalDiffTime, UTCTime, getCurrentTime)+import Data.Time (NominalDiffTime, UTCTime, defaultTimeLocale, getCurrentTime, parseTimeM) import Data.UUID (UUID) import Data.UUID qualified as UUID-import Data.Vector qualified as Vector import Effectful (Eff, IOE, (:>)) import Effectful.Error.Static (Error)-import Keiro.Codec (decodeRecorded)+import Keiro.Inspection.Cursor (InspectionCursor, maxCursorBytes) import Keiro.Ops.Env (OpsEnv (..), OutputMode (..))-import Keiro.Ops.Parse (durationReader, nonNegativeReader, positiveIntReader)+import Keiro.Ops.Parse (cursorReader, durationReader, nonNegativeReader, positiveIntReader) import Keiro.Ops.Render import Keiro.Workflow.Awakeable (AwakeableId (..), cancelAwakeable, signalAwakeable) import Keiro.Workflow.Awakeable.Schema qualified as Awakeable import Keiro.Workflow.Child.Schema qualified as Child import Keiro.Workflow.Gc qualified as Gc+import Keiro.Workflow.Inspection qualified as Inspection import Keiro.Workflow.Instance qualified as Instance import Keiro.Workflow.Resume ( ResumeSummary (..),@@ -52,20 +51,10 @@ import Keiro.Workflow.Schema qualified as WorkflowSchema import Keiro.Workflow.Types ( WorkflowId (..),- WorkflowJournalEvent (..), WorkflowName (..),- awakeableAllocStepPrefix,- cancelledStepName,- completedStepName,- continuedAsNewStepName,- failedStepName,- workflowGenerationStreamName,- workflowJournalCodec, ) import Kiroku.Store.Effect (Store, runStoreIO) import Kiroku.Store.Error (StoreError)-import Kiroku.Store.Read qualified as StoreRead-import Kiroku.Store.Types import Options.Applicative hiding (action, value) import Options.Applicative qualified as Optparse @@ -119,6 +108,7 @@ data Command = List !ListOptions+ | Inspect !InspectionCommand | Show !WorkflowRef | Steps !InspectOptions | Journal !InspectOptions@@ -130,6 +120,15 @@ | ResumeOnce !ResumeOptions deriving stock (Eq, Show) +data InspectionCommand+ = InspectList !Inspection.WorkflowListQuery+ | InspectHeader !WorkflowRef+ | InspectJournal !WorkflowRef !Inspection.DetailPageQuery+ | InspectSteps !WorkflowRef !Inspection.DetailPageQuery+ | InspectChildren !WorkflowRef !Inspection.OwnerPageQuery+ | InspectAwakeables !WorkflowRef !Inspection.OwnerPageQuery+ deriving stock (Eq, Show)+ commandParser :: Parser Command commandParser = commandParserWithResume False @@ -137,6 +136,7 @@ commandParserWithResume includeResume = hsubparser ( command "list" (info (List <$> listOptionsParser) (progDesc "List workflow instances with stable keyset paging"))+ <> command "inspect" (info (Inspect <$> inspectionParser) (progDesc "Bounded workflow observation pages")) <> command "show" (info (Show <$> workflowRefParser) (progDesc "Show an instance, its children, and its awakeables")) <> command "steps" (info (Steps <$> inspectOptionsParser) (progDesc "Show the derived step index for one generation")) <> command "journal" (info (Journal <$> inspectOptionsParser) (progDesc "Decode one workflow journal generation in order"))@@ -180,6 +180,40 @@ <$> workflowRefParser <*> optional (option (nonNegativeReader "expected a non-negative generation") (long "generation" <> metavar "N" <> help "Journal generation; defaults to current")) +inspectionParser :: Parser InspectionCommand+inspectionParser =+ hsubparser+ ( command "list" (info (InspectList <$> listQuery) (progDesc "Page workflow instances"))+ <> command "header" (info (InspectHeader <$> workflowRefParser) (progDesc "Read only the instance header"))+ <> command "journal" (info (InspectJournal <$> workflowRefParser <*> detailQuery) (progDesc "Page one journal generation"))+ <> command "steps" (info (InspectSteps <$> workflowRefParser <*> detailQuery) (progDesc "Page one generation's step index"))+ <> command "children" (info (InspectChildren <$> workflowRefParser <*> ownerQuery) (progDesc "Page child links"))+ <> command "awakeables" (info (InspectAwakeables <$> workflowRefParser <*> ownerQuery) (progDesc "Page allocated external promises"))+ )+ where+ limit = option positiveIntReader (long "limit" <> metavar "N" <> Optparse.value 100 <> help "Page size, at most 500")+ after = optional (option cursorReader (long "after" <> metavar "CURSOR"))+ detailQuery =+ Inspection.DetailPageQuery+ <$> optional (option (nonNegativeReader "expected a non-negative generation") (long "generation" <> metavar "N"))+ <*> limit+ <*> after+ ownerQuery = Inspection.OwnerPageQuery <$> limit <*> after+ listQuery =+ Inspection.WorkflowListQuery+ <$> (NonEmpty.nonEmpty <$> many (option statusClassReader (long "status-class" <> metavar "CLASS")))+ <*> optional (Text.pack <$> strOption (long "workflow-name" <> metavar "NAME"))+ <*> optional (option utcReader (long "created-since" <> metavar "UTC"))+ <*> optional (option utcReader (long "created-before" <> metavar "UTC"))+ <*> optional (option utcReader (long "updated-since" <> metavar "UTC"))+ <*> optional (option utcReader (long "updated-before" <> metavar "UTC"))+ <*> option orderingReader (long "ordering" <> metavar "key|created_desc" <> Optparse.value Inspection.ByKey)+ <*> limit+ <*> after+ statusClassReader = eitherReader $ \raw -> maybe (Left "expected running, retrying, sleeping, awaiting, completed, cancelled, or failed") Right (Inspection.statusClassFromText (Text.pack raw))+ orderingReader = eitherReader $ \case "key" -> Right Inspection.ByKey; "created_desc" -> Right Inspection.ByCreatedDesc; _ -> Left "expected key or created_desc"+ utcReader = eitherReader $ \raw -> maybe (Left "expected a UTC timestamp such as 2026-10-20T01:02:03Z") Right (parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%S%QZ" raw)+ workflowRefParser :: Parser WorkflowRef workflowRefParser = WorkflowRef@@ -245,6 +279,7 @@ isMutation :: Command -> Bool isMutation = \case+ Inspect {} -> False List {} -> False Show {} -> False Steps {} -> False@@ -263,6 +298,7 @@ runCommandWithResume :: Maybe ResumeHook -> OpsEnv -> Command -> IO OpsOutcome runCommandWithResume resumeHook env = \case+ Inspect inspection -> runInspection env inspection List options -> runList env options Show ref -> runShow env ref Steps options -> runSteps env options@@ -274,6 +310,53 @@ GcRunOnce options -> runGc env options ResumeOnce options -> runResumeOnce resumeHook env options +runInspection :: OpsEnv -> InspectionCommand -> IO OpsOutcome+runInspection env = \case+ InspectList query -> page (Inspection.listWorkflowInstancesPage query)+ InspectHeader ref -> runAction env (Instance.lookupInstance (refName ref) (refId ref)) (headerOutcome ref)+ InspectJournal ref query -> page (Inspection.readWorkflowJournalPage (refName ref) (refId ref) query)+ InspectSteps ref query -> page (Inspection.loadStepIndexPage (refName ref) (refId ref) query)+ InspectChildren ref query -> page (Inspection.listWorkflowChildrenPage (refName ref) (refId ref) query)+ InspectAwakeables ref query -> page (Inspection.listWorkflowAwakeablesPage (refName ref) (refId ref) query)+ where+ page :: (Aeson.ToJSON a) => Eff '[Store, Error StoreError, IOE] (Either Inspection.InspectionError a) -> IO OpsOutcome+ page action = runAction env action (either inspectionFailure (Succeeded . jsonResult . Aeson.toJSON))+ jsonResult value = OpsResult ["result"] [[jsonText value]] value++-- | Canonical CLI and HTTP outcome for a freshly observed header.+headerOutcome :: WorkflowRef -> Maybe Instance.WorkflowInstanceRow -> OpsOutcome+headerOutcome ref = \case+ Nothing -> Failed (workflowLabel ref <> " was not found")+ Just row ->+ let value = Inspection.instanceToJson row+ in Succeeded (OpsResult ["result"] [[jsonText value]] value)++inspectionFailure :: Inspection.InspectionError -> OpsOutcome+inspectionFailure err = uncurry InspectionFailed (inspectionError err)++inspectionError :: Inspection.InspectionError -> (Text, Text)+inspectionError err = (code, message)+ where+ message = case err of+ Inspection.CursorResultTooLarge -> "the continuation key exceeds the " <> Text.pack (show maxCursorBytes) <> "-byte cursor limit"+ _ -> Text.pack (show err)+ code = case err of+ Inspection.InvalidPageSize _ -> "invalid_page_size"+ Inspection.InvalidCursor _ -> "invalid_cursor"+ Inspection.CursorGenerationMismatch _ _ -> "cursor_generation_mismatch"+ Inspection.CursorResultTooLarge -> "inspection_result_too_large"++-- | Validate the owning library's inspection grammar without any SQL.+validateCommand :: Command -> Either (Text, Text) ()+validateCommand (Inspect command) = either (Left . inspectionError) Right $ case command of+ InspectList query -> Inspection.validateWorkflowListQuery query+ InspectHeader _ -> Right ()+ InspectJournal ref query -> Inspection.validateJournalQuery (refName ref) (refId ref) query+ InspectSteps ref query -> Inspection.validateStepQuery (refName ref) (refId ref) query+ InspectChildren ref query -> Inspection.validateChildQuery (refName ref) (refId ref) query+ InspectAwakeables ref query -> Inspection.validateAwakeableQuery (refName ref) (refId ref) query+validateCommand _ = Right ()+ runResumeOnce :: Maybe ResumeHook -> OpsEnv -> ResumeOptions -> IO OpsOutcome runResumeOnce Nothing _ _ = pure (Failed "workflow resume hook is not mounted")@@ -360,41 +443,49 @@ options.limit runShow :: OpsEnv -> WorkflowRef -> IO OpsOutcome-runShow env ref =- runAction env action $ \case- Nothing -> Failed (workflowLabel ref <> " was not found")- Just details -> Succeeded (workflowDetailsResult details)+runShow env ref = runAction env action $ \case+ Left err -> inspectionFailure err+ Right Nothing -> Failed (workflowLabel ref <> " was not found")+ Right (Just details) -> Succeeded (workflowDetailsResult details) where action = do instanceRow <- Instance.lookupInstance (refName ref) (refId ref) case instanceRow of- Nothing -> pure Nothing+ Nothing -> pure (Right Nothing) Just row -> do- children <- Child.lookupChildrenOfParent ref.workflowId ref.workflowName- awakeables <- lookupWorkflowAwakeables ref- pure (Just (row, children, awakeables))+ children <- drainPages (\cursor -> Inspection.listWorkflowChildrenPage (refName ref) (refId ref) (Inspection.OwnerPageQuery 500 cursor)) (.items) (.nextCursor)+ awakeables <- drainPages (\cursor -> Inspection.listWorkflowAwakeablesPage (refName ref) (refId ref) (Inspection.OwnerPageQuery 500 cursor)) (.items) (.nextCursor)+ pure ((\cs as -> Just (row, cs, as)) <$> children <*> awakeables) runSteps :: OpsEnv -> InspectOptions -> IO OpsOutcome-runSteps env options =- runAction env action (Succeeded . uncurry stepsResult)+runSteps env options = runAction env action $ \(gen, rows) -> either inspectionFailure (Succeeded . stepsResult gen) rows where action = do- selectedGeneration <- resolveGeneration options- steps <- WorkflowSchema.loadStepIndex (refName options.target) (refId options.target) selectedGeneration- pure (selectedGeneration, steps)+ gen <- resolveGeneration options+ rows <- drainPages (\cursor -> Inspection.loadStepIndexPage (refName options.target) (refId options.target) (Inspection.DetailPageQuery (Just gen) 500 cursor)) (.items) (.nextCursor)+ pure (gen, rows) runJournal :: OpsEnv -> InspectOptions -> IO OpsOutcome-runJournal env options =- runAction env action $ \(selectedGeneration, recorded) ->- case traverse decodeJournalView recorded of- Left err -> Failed err- Right views -> Succeeded (journalResult selectedGeneration views)+runJournal env options = runAction env action $ \(gen, rows) -> either inspectionFailure (Succeeded . journalResult gen) rows where action = do- selectedGeneration <- resolveGeneration options- recorded <- readJournalEvents (workflowGenerationStreamName (refName options.target) (refId options.target) selectedGeneration)- pure (selectedGeneration, recorded)+ gen <- resolveGeneration options+ rows <- drainPages (\cursor -> Inspection.readWorkflowJournalPage (refName options.target) (refId options.target) (Inspection.DetailPageQuery (Just gen) 500 cursor)) (.items) (.nextCursor)+ pure (gen, rows) +-- Compatibility commands intentionally drain pages. Bounded inspect commands+-- above invoke one library page only, and the HTTP deadline still bounds these.+drainPages :: (Maybe InspectionCursor -> Eff es (Either Inspection.InspectionError page)) -> (page -> [a]) -> (page -> Maybe InspectionCursor) -> Eff es (Either Inspection.InspectionError [a])+drainPages load items next = go Nothing []+ where+ go cursor chunks = do+ result <- load cursor+ case result of+ Left err -> pure (Left err)+ Right page -> case next page of+ Nothing -> pure (Right (concat (reverse (items page : chunks))))+ Just token -> go (Just token) (items page : chunks)+ runAwakeable :: OpsEnv -> AwakeableCommand -> IO OpsOutcome runAwakeable env = \case AwakeableShow awakeableId ->@@ -514,76 +605,12 @@ pure options.generation -lookupWorkflowAwakeables :: (Store :> es) => WorkflowRef -> Eff es [Awakeable.AwakeableRow]-lookupWorkflowAwakeables ref = do- current <- WorkflowSchema.currentGeneration (refName ref) (refId ref)- stepIndexes <- traverse (WorkflowSchema.loadStepIndex (refName ref) (refId ref)) [0 .. current]- catMaybes <$> traverse Awakeable.lookupAwakeable (awakeableIds stepIndexes)--awakeableIds :: [Map Text Value] -> [UUID]-awakeableIds indexes =- Set.toAscList . Set.fromList $ do- index <- indexes- (stepName, value) <- Map.toList index- if awakeableAllocStepPrefix `Text.isPrefixOf` stepName- then case Aeson.fromJSON value of- AesonTypes.Success (AwakeableId awakeableId) -> [awakeableId]- AesonTypes.Error _ -> []- else []--readJournalEvents :: (Store :> es) => StreamName -> Eff es [RecordedEvent]-readJournalEvents streamName = go (StreamVersion 0) []- where- pageSize = 256- go cursor pages = do- page <- StoreRead.readStreamForward streamName cursor pageSize- if Vector.null page- then pure (concat (reverse pages))- else- let nextCursor = (Vector.last page).streamVersion- in go nextCursor (Vector.toList page : pages)--data JournalView = JournalView- { eventId :: !Text,- eventType :: !Text,- streamVersion :: !Int64,- globalPosition :: !Int64,- stepName :: !Text,- recordedAt :: !UTCTime,- payload :: !Value- }--decodeJournalView :: RecordedEvent -> Either Text JournalView-decodeJournalView recorded = do- event <- firstShow (decodeRecorded workflowJournalCodec recorded)- let (stepName, recordedAt, payload) = case event of- StepRecorded name value timestamp -> (name, timestamp, value)- WorkflowCompleted timestamp -> (completedStepName, timestamp, Aeson.Null)- WorkflowCancelled timestamp -> (cancelledStepName, timestamp, Aeson.Null)- WorkflowFailed reason timestamp -> (failedStepName, timestamp, Aeson.toJSON reason)- WorkflowContinuedAsNew generation timestamp -> (continuedAsNewStepName, timestamp, Aeson.toJSON generation)- pure- JournalView- { eventId = case recorded.eventId of EventId value -> UUID.toText value,- eventType = case recorded.eventType of EventType value -> value,- streamVersion = case recorded.streamVersion of StreamVersion value -> value,- globalPosition = case recorded.globalPosition of GlobalPosition value -> value,- stepName,- recordedAt,- payload- }--firstShow :: (Show err) => Either err value -> Either Text value-firstShow = \case- Left err -> Left ("workflow journal decode failed: " <> Text.pack (show err))- Right value -> Right value- workflowListResult :: [Instance.WorkflowInstanceRow] -> OpsResult workflowListResult instances = OpsResult { headers = ["name", "id", "generation", "status", "attempts", "lease", "wake_after", "updated_at"], rows = map workflowListRow instances,- jsonValue = Aeson.toJSON (map workflowInstanceJson instances)+ jsonValue = Aeson.toJSON (map Inspection.instanceToJson instances) } workflowListRow :: Instance.WorkflowInstanceRow -> [Text]@@ -598,24 +625,6 @@ timeText row.updatedAt ] -workflowInstanceJson :: Instance.WorkflowInstanceRow -> Value-workflowInstanceJson row =- object- [ "workflow_id" .= row.workflowId,- "workflow_name" .= row.workflowName,- "generation" .= row.generation,- "status" .= Instance.statusToText row.status,- "attempts" .= row.attempts,- "last_error" .= row.lastError,- "next_attempt_at" .= row.nextAttemptAt,- "wake_after" .= row.wakeAfter,- "leased_by" .= row.leasedBy,- "lease_expires_at" .= row.leaseExpiresAt,- "created_at" .= row.createdAt,- "updated_at" .= row.updatedAt,- "completed_at" .= row.completedAt- ]- workflowDetailsResult :: (Instance.WorkflowInstanceRow, [Child.ChildRow], [Awakeable.AwakeableRow]) -> OpsResult workflowDetailsResult (row, children, awakeables) = OpsResult@@ -634,41 +643,25 @@ ], jsonValue = object- [ "instance" .= workflowInstanceJson row,- "children" .= map childJson children,- "awakeables" .= map awakeableJson awakeables+ [ "instance" .= Inspection.instanceToJson row,+ "children" .= map Inspection.childToJson children,+ "awakeables" .= map Inspection.awakeableToJson awakeables ] } -childJson :: Child.ChildRow -> Value-childJson row =- object- [ "child_id" .= row.childId,- "child_name" .= row.childName,- "parent_id" .= row.parentId,- "parent_name" .= row.parentName,- "await_step" .= row.awaitStep,- "status" .= Child.statusToText row.status,- "result" .= row.result,- "failure_reason" .= row.failureReason,- "created_at" .= row.createdAt,- "updated_at" .= row.updatedAt,- "completed_at" .= row.completedAt- ]--stepsResult :: Int -> Map Text Value -> OpsResult+stepsResult :: Int -> [WorkflowSchema.WorkflowStepRow] -> OpsResult stepsResult generation steps = OpsResult { headers = ["step", "result"],- rows = [[name, truncateCell 120 (jsonText value)] | (name, value) <- Map.toAscList steps],+ rows = [[row.stepName, truncateCell 120 (jsonText row.result)] | row <- steps], jsonValue = object [ "generation" .= generation,- "steps" .= [object ["step" .= name, "result" .= value] | (name, value) <- Map.toAscList steps]+ "steps" .= map Inspection.stepToJson steps ] } -journalResult :: Int -> [JournalView] -> OpsResult+journalResult :: Int -> [Inspection.WorkflowJournalEntry] -> OpsResult journalResult generation views = OpsResult { headers = ["version", "event_type", "step", "recorded_at", "payload"],@@ -681,21 +674,9 @@ ] | view <- views ],- jsonValue = object ["generation" .= generation, "events" .= map journalViewJson views]+ jsonValue = object ["generation" .= generation, "events" .= map Inspection.journalEntryToJson views] } -journalViewJson :: JournalView -> Value-journalViewJson view =- object- [ "event_id" .= view.eventId,- "event_type" .= view.eventType,- "stream_version" .= view.streamVersion,- "global_position" .= view.globalPosition,- "step_name" .= view.stepName,- "recorded_at" .= view.recordedAt,- "payload" .= view.payload- ]- awakeableResult :: Awakeable.AwakeableRow -> OpsResult awakeableResult row = OpsResult@@ -709,22 +690,9 @@ timeText row.updatedAt ] ],- jsonValue = awakeableJson row+ jsonValue = Inspection.awakeableToJson row } -awakeableJson :: Awakeable.AwakeableRow -> Value-awakeableJson row =- object- [ "awakeable_id" .= UUID.toText row.awakeableId,- "owner_workflow_name" .= row.ownerWorkflowName,- "owner_workflow_id" .= row.ownerWorkflowId,- "status" .= Awakeable.statusToText row.status,- "payload" .= row.payload,- "created_at" .= row.createdAt,- "updated_at" .= row.updatedAt,- "completed_at" .= row.completedAt- ]- awakeablePreviewResult :: Text -> UUID -> Maybe Awakeable.AwakeableRow -> OpsResult awakeablePreviewResult operation awakeableId row = OpsResult@@ -735,7 +703,7 @@ [ "preview" .= True, "operation" .= operation, "disposition" .= disposition,- "awakeable" .= fmap awakeableJson row+ "awakeable" .= fmap Inspection.awakeableToJson row ] } where@@ -756,7 +724,7 @@ [ "operation" .= operation, "outcome" .= outcome, "transitioned" .= transitioned,- "awakeable" .= fmap awakeableJson row+ "awakeable" .= fmap Inspection.awakeableToJson row ] } where@@ -775,7 +743,7 @@ "operation" .= operation, "disposition" .= disposition, "target" .= object ["workflow_name" .= ref.workflowName, "workflow_id" .= ref.workflowId],- "instance" .= fmap workflowInstanceJson row+ "instance" .= fmap Inspection.instanceToJson row ] }
+ src/Keiro/Ops/WorkflowHeaderCache.hs view
@@ -0,0 +1,103 @@+-- | Bounded reuse of pure header presentation. Every lookup still reads the+-- complete current header in PostgreSQL; callers must verify live schema first.+module Keiro.Ops.WorkflowHeaderCache+ ( WorkflowHeaderCache,+ newWorkflowHeaderCache,+ workflowHeaderCacheUsage,+ runHeaderCached,+ )+where++import Data.Aeson (Value)+import Data.ByteString (ByteString)+import Data.ByteString qualified as ByteString+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Sequence qualified as Seq+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Hasql.Pool qualified as Pool+import Keiro.Ops.Env (OpsEnv (..))+import Keiro.Ops.Render+import Keiro.Ops.Workflow (WorkflowRef (..), headerOutcome)+import Keiro.Workflow.Instance qualified as Instance+import Keiro.Workflow.Types (WorkflowId (..), WorkflowName (..))+import Kiroku.Store.Connection (KirokuStore (..))+import Kiroku.Store.Error (mapGenericUsageError)++type View = (Text, Text)++data Entry = Entry !Instance.WorkflowHeaderKey !OpsResult !ByteString !Integer++data Entries = Entries !(Map.Map View Entry) !(Seq.Seq View) !Integer++data WorkflowHeaderCache = WorkflowHeaderCache+ { entries :: !(IORef Entries),+ imageLimit :: !Int,+ encode :: Value -> IO (Maybe ByteString)+ }++-- | At most 32 presentations, charged at most 1 MiB in total. Charge includes+-- input keys, exact row images, JSON values, rendered cells and encoded bodies.+-- A larger valid response can still be served without retaining its rendering.+-- The encoder must enforce the host's response cap and return exact JSON bytes.+newWorkflowHeaderCache :: Int -> (Value -> IO (Maybe ByteString)) -> IO WorkflowHeaderCache+newWorkflowHeaderCache responseLimit encode+ | responseLimit <= 0 = ioError (userError "header response limit must be positive")+ | otherwise = do+ entries <- newIORef (Entries Map.empty Seq.empty 0)+ -- PostgreSQL's image has field names and date arrays independent of the+ -- public wire format. Allow its fixed overhead without an Int overflow.+ let imageLimit = fromInteger (min (toInteger (maxBound :: Int)) (2 * toInteger responseLimit + 4096))+ pure WorkflowHeaderCache {entries, imageLimit, encode}++-- | Charged retained bytes and entry count, for host diagnostics.+workflowHeaderCacheUsage :: WorkflowHeaderCache -> IO (Int, Integer)+workflowHeaderCacheUsage cache = do+ Entries known _ bytes <- readIORef cache.entries+ pure (Map.size known, bytes)++runHeaderCached :: WorkflowHeaderCache -> OpsEnv -> WorkflowRef -> IO (OpsOutcome, Maybe ByteString)+runHeaderCached cache env ref = do+ Entries known _ _ <- readIORef cache.entries+ let view = (ref.workflowName, ref.workflowId)+ previous = Map.lookup view known+ key = case previous of Just (Entry image _ _ _) -> Just image; Nothing -> Nothing+ forget = atomicModifyIORef' cache.entries (\entries -> (remove view entries, ()))+ failure code message = forget >> pure (InspectionFailed code message, Nothing)+ observed <- Pool.use env.store.pool (Instance.lookupInstanceConditionalSession cache.imageLimit key (WorkflowName ref.workflowName) (WorkflowId ref.workflowId))+ case observed of+ Left err -> forget >> pure (Failed (Text.pack (show (mapGenericUsageError err))), Nothing)+ Right Nothing -> forget >> pure (headerOutcome ref Nothing, Nothing)+ Right (Just Instance.HeaderImageTooLarge) -> failure "inspection_result_too_large" "workflow header image exceeds the host limit"+ Right (Just Instance.HeaderUnchanged) -> case previous of+ Just (Entry _ result body _) -> pure (Succeeded result, Just body)+ Nothing -> failure "inspection_header_inconsistent" "header comparison returned no initial image"+ Right (Just (Instance.HeaderChanged image row)) -> case headerOutcome ref (Just row) of+ Succeeded result -> do+ encoded <- cache.encode result.jsonValue+ case encoded of+ Nothing -> failure "inspection_result_too_large" "encoded result exceeds the host limit; narrow the read"+ Just body -> do+ let charge = 4096 + toInteger (ByteString.length (Text.encodeUtf8 ref.workflowName)) + toInteger (ByteString.length (Text.encodeUtf8 ref.workflowId)) + 3 * toInteger (Instance.workflowHeaderKeyBytes image) + 6 * toInteger (ByteString.length body)+ entry = Entry image result body charge+ atomicModifyIORef' cache.entries (\entries -> (insert view entry entries, ()))+ pure (Succeeded result, Just body)+ outcome -> forget >> pure (outcome, Nothing)++remove :: View -> Entries -> Entries+remove view (Entries known order bytes) = case Map.lookup view known of+ Nothing -> Entries known order bytes+ Just (Entry _ _ _ charge) -> Entries (Map.delete view known) (Seq.filter (/= view) order) (bytes - charge)++insert :: View -> Entry -> Entries -> Entries+insert view entry@(Entry _ _ _ charge) entries =+ let without = remove view entries+ in if charge > 1024 * 1024 then without else fit without+ where+ fit (Entries known order bytes)+ | Map.size known < 32 && bytes + charge <= 1024 * 1024 = Entries (Map.insert view entry known) (order Seq.|> view) (bytes + charge)+ | otherwise = case Seq.viewl order of+ oldest Seq.:< _ -> fit (remove oldest (Entries known order bytes))+ Seq.EmptyL -> Entries Map.empty Seq.empty 0
test/Main.hs view
@@ -29,6 +29,7 @@ import Hasql.Transaction qualified as Tx import Keiro.DeadLetter import Keiro.Inbox qualified as Inbox+import Keiro.Inspection.Cursor (InspectionCursor (..)) import Keiro.Integration.Event import Keiro.Ops (AppHooks (..)) import Keiro.Ops qualified as Ops@@ -74,6 +75,7 @@ import Kiroku.Store.Types import Options.Applicative qualified as Optparse import Pgmq.Migration qualified as PgmqMigration+import ReactionInspectionSpec qualified import System.Exit (ExitCode (..)) import System.Process (readProcessWithExitCode) import Test.Hspec@@ -89,7 +91,9 @@ { workflowResume = Just (Map.empty, defaultWorkflowResumeOptions), timerFire = Just (\_ -> pure Nothing), replayAudit = Just (OpsReplayAudit.OpsAuditConfig []),- projectionCatalog = Just emptyCatalogOperations+ projectionCatalog = Just emptyCatalogOperations,+ aggregates = Just Map.empty,+ processManagers = Just Map.empty } emptyCatalogOperations :: CatalogOperations.ProjectionCatalogOperations@@ -111,8 +115,12 @@ spec :: Fixture -> Spec spec fixture = do+ ReactionInspectionSpec.spec fixture describe "embedded command tree" do it "omits code-dependent commands from the standalone tree" do+ isParseSuccess (parseOps Ops.emptyAppHooks ["pm", "show", "--name", "test", "--correlation", "one"]) `shouldBe` False+ isParseSuccess (parseOps Ops.emptyAppHooks ["pm", "types"]) `shouldBe` False+ isParseSuccess (parseOps Ops.emptyAppHooks ["aggregate", "types"]) `shouldBe` False isParseFailure (parseOps Ops.emptyAppHooks ["wf", "resume-once"]) `shouldBe` True isParseFailure (parseOps Ops.emptyAppHooks ["timer", "drain-once"]) `shouldBe` True isParseFailure (parseOps Ops.emptyAppHooks ["replay-audit", "--full"]) `shouldBe` True@@ -124,6 +132,14 @@ isParseFailure (parseOps Ops.emptyAppHooks ["rebuild", "reproject-stream", "ops-group", "ops-projection", "orders-1"]) `shouldBe` True it "mounts every code-dependent command from typed application hooks" do+ isParseSuccess (parseOps embeddedHooks ["pm", "show", "--name", "test", "--correlation", "one"]) `shouldBe` True+ isParseSuccess (parseOps embeddedHooks ["pm", "list", "--name", "test"]) `shouldBe` True+ isParseSuccess (parseOps embeddedHooks ["pm", "types"]) `shouldBe` True+ isParseSuccess (parseOps embeddedHooks ["pm", "types", "--name", "test"]) `shouldBe` False+ isParseSuccess (parseOps embeddedHooks ["aggregate", "types"]) `shouldBe` True+ isParseSuccess (parseOps embeddedHooks ["aggregate", "show", "--name", "counter", "--id", "one"]) `shouldBe` True+ isParseSuccess (parseOps embeddedHooks ["aggregate", "snapshot", "--name", "counter", "--id", "one"]) `shouldBe` True+ isParseSuccess (parseOps embeddedHooks ["aggregate", "list", "--name", "counter", "--limit", "2"]) `shouldBe` True isParseSuccess (parseOps embeddedHooks ["wf", "resume-once"]) `shouldBe` True isParseSuccess (parseOps embeddedHooks ["timer", "drain-once"]) `shouldBe` True isParseSuccess (parseOps embeddedHooks ["replay-audit", "--full"]) `shouldBe` True@@ -977,6 +993,34 @@ workflowStatus store ref `shouldReturn` Nothing describe "timer handlers" $ around (withFreshStore fixture) do+ it "renders pending timer pages with reusable cursors and due time" $ \store -> do+ now <- getCurrentTime+ let request = timerRequest "018f5f43-8a70-7b9a-9a9b-59d391a76720" (addUTCTime (-60) now)+ another = timerRequest "018f5f43-8a70-7b9a-9a9b-59d391a76721" (addUTCTime (-60) now)+ options = OpsTimer.PendingListOptions Nothing Nothing Nothing 1+ expectStore store (runTransaction (Timer.scheduleTimerTx request >> Timer.scheduleTimerTx another))+ first <- OpsTimer.runCommand (opsEnv False store) (OpsTimer.PendingList options)+ case first of+ Succeeded result -> case result.jsonValue of+ Aeson.Object fields -> do+ Just (Aeson.String cursor) <- pure (KeyMap.lookup "next_cursor" fields)+ Just (Aeson.Array items) <- pure (KeyMap.lookup "items" fields)+ Vector.length items `shouldBe` 1+ case Vector.head items of+ Aeson.Object item -> KeyMap.lookup "due_in_seconds" item `shouldSatisfy` (\case Just (Aeson.Number n) -> n < 0; _ -> False)+ _ -> expectationFailure "expected timer item"+ second <- OpsTimer.runCommand (opsEnv False store) (OpsTimer.PendingList (options {OpsTimer.after = Just (InspectionCursor cursor)}))+ case second of+ Succeeded rendered -> case rendered.jsonValue of+ Aeson.Object page -> KeyMap.lookup "next_cursor" page `shouldBe` Nothing+ _ -> expectationFailure "expected timer page"+ _ -> expectationFailure (show second)+ _ -> expectationFailure "expected timer page"+ _ -> expectationFailure (show first)+ filtered <- OpsTimer.runCommand (opsEnv False store) (OpsTimer.PendingList (options {OpsTimer.manager = Just "missing"}))+ filtered `shouldSatisfy` isSucceeded+ timerStatus store request.timerId `shouldReturn` Just Timer.Scheduled+ it "previews and dispatches one bounded due-timer pass through the mounted hook" $ \store -> do now <- getCurrentTime let request = timerRequest "018f5f43-8a70-7b9a-9a9b-59d391a76722" (addUTCTime (-60) now)@@ -1253,6 +1297,22 @@ dlqDepth `shouldReturn` 0 describe "projection handlers" $ around (withFreshStore fixture) do+ it "renders bounded projection status through the owning public relation" $ \store -> do+ expectStore store (runTransaction (Tx.sql "CREATE SCHEMA app; CREATE TABLE app.ops_catalog (id bigint PRIMARY KEY)"))+ catalog <- expectValidatedCatalog (opsCatalog "status-codec")+ _ <- expectStore store (Rebuild.registerProjectionCatalog catalog)+ Right page <- expectStore store (Rebuild.listProjectionGroupStatusesPage (Rebuild.ProjectionStatusPageRequest 1 Nothing))+ shown <- OpsProjection.runCommand (opsEnv False store) (OpsProjection.Status (OpsProjection.ProjectionStatusOptions 1 Nothing))+ case shown of+ Succeeded result -> case result.jsonValue of+ Aeson.Object fields -> do+ KeyMap.lookup "items" fields `shouldBe` Just (Aeson.toJSON (map OpsProjection.projectionGroupStatusJson page.items))+ KeyMap.member "observed_at" fields `shouldBe` True+ KeyMap.lookup "visible_store_head" fields `shouldBe` Just (Aeson.toJSON (0 :: Int))+ KeyMap.lookup "next_cursor" fields `shouldBe` Nothing+ _ -> expectationFailure "expected projection status page"+ _ -> expectationFailure (show shown)+ it "prunes only the named dedup rows" $ \store -> do _ <- seedKirokuEvent store "projection-source" "018f5f43-8a70-7b9a-9a9b-59d391a76810" Nothing events <- expectStore store (readStreamForward (StreamName "projection-source") (StreamVersion 0) 1)@@ -1285,6 +1345,32 @@ status <- OpsShard.runCommand (opsEnv False store) (OpsShard.Status "ops-shards") resultArrayLengthFromObject "ownership" status `shouldBe` Just 2 + detailed <- expectStore store (Shard.shardOwnershipView subscription)+ case status of+ Succeeded result -> case result.jsonValue of+ Aeson.Object fields -> do+ KeyMap.member "observed_at" fields `shouldBe` True+ Just (Aeson.Array ownership) <- pure (KeyMap.lookup "ownership" fields)+ Vector.length ownership `shouldBe` length detailed.buckets+ case Vector.head ownership of+ Aeson.Object row -> do+ KeyMap.member "heartbeat_at" row `shouldBe` True+ KeyMap.member "lease_age_seconds" row `shouldBe` True+ _ -> expectationFailure "expected ownership row"+ _ -> expectationFailure "expected shard status"+ _ -> expectationFailure (show status)+ listed <- OpsShard.runCommand (opsEnv False store) OpsShard.List+ case listed of+ Succeeded result -> case result.jsonValue of+ Aeson.Object fields -> do+ Just (Aeson.Array items) <- pure (KeyMap.lookup "items" fields)+ Vector.length items `shouldBe` 1+ case Vector.head items of+ Aeson.Object row -> KeyMap.lookup "buckets_live" row `shouldBe` Just (Aeson.toJSON (2 :: Int))+ _ -> expectationFailure "expected shard summary"+ _ -> expectationFailure "expected shard list"+ _ -> expectationFailure (show listed)+ preview <- OpsShard.runCommand (opsEnv False store) (OpsShard.Relinquish "ops-shards" worker) preview `shouldSatisfy` isPreview ownersBefore <- expectStore store (Shard.ownershipSnapshotFor subscription)@@ -1316,6 +1402,15 @@ regfileShapeHash = expected.regfileShapeHash, stateShapeHash = expected.stateShapeHash }++ shown <- OpsSnapshot.runCommand (opsEnv False store) (OpsSnapshot.Show "snapshot-ops")+ case shown of+ Succeeded result -> case result.jsonValue of+ Aeson.Object fields -> do+ KeyMap.lookup "observed_at" fields `shouldSatisfy` (\case Just (Aeson.String _) -> True; _ -> False)+ KeyMap.lookup "age_seconds" fields `shouldSatisfy` (\case Just (Aeson.Number n) -> n >= 0; _ -> False)+ _ -> expectationFailure "expected snapshot JSON object"+ _ -> expectationFailure "expected snapshot show success" missing <- OpsSnapshot.runCommand (opsEnv False store) (OpsSnapshot.TruncationPreflight "no-snapshot" (StreamVersion 2) (Just expected)) jsonBool "passed" missing `shouldBe` Just False
+ test/ReactionInspectionSpec.hs view
@@ -0,0 +1,170 @@+module ReactionInspectionSpec (spec) where++import Control.Exception (finally)+import Control.Monad (when)+import Data.Aeson (Value (..), object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.Encoding qualified as Encoding+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Lazy qualified as Lazy+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (getCurrentTime)+import Data.UUID qualified as UUID+import Keiki.Core+import Keiro+import Keiro.Ops qualified as Ops+import Keiro.Ops.Env+import Keiro.Ops.ProcessManager qualified as PM+import Keiro.Ops.Render+import Keiro.Ops.WorkflowHeaderCache (newWorkflowHeaderCache, workflowHeaderCacheUsage)+import Keiro.ProcessManager.Inspect qualified as Inspect+import Keiro.ProcessManager.Reaction qualified as Reaction+import Keiro.Stream qualified as Stream+import Keiro.Test.Postgres+import Keiro.Timer qualified as Timer+import Kiroku.Store.Connection (ConnectionSettingsM (..))+import Kiroku.Store.Effect (runStoreIO)+import Kiroku.Store.Read (getStream)+import Kiroku.Store.Types hiding (id)+import Options.Applicative qualified as Options+import Test.Hspec++spec :: Fixture -> Spec+spec fixture = describe "reaction process-manager commands" $+ around (withFreshResourceStore fixture) $ do+ it "lists metadata with an unusable store and fully renders both formats" $ \_ -> do+ armed <- newIORef False+ let observe _ = readIORef armed >>= \active -> when active (fail "pm types touched the store")+ withFreshResourceStoreWith fixture (\settings -> settings {observationHandler = Just observe}) $ \(unusable, _) ->+ flip finally (writeIORef armed False) $ do+ writeIORef armed True+ let unused = error "pm types evaluated an application callback"+ manager = Reaction.ReactiveProcessManager "modern" id handler (\_ -> unused) definition (const []) (\_ -> unused)+ descriptor =+ (Inspect.reactiveProcessManagerInspector manager (Stream.categoryUnsafe "opsreaction") (\_ _ -> unused))+ { Inspect.correlationOf = \_ -> unused,+ Inspect.stateLabel = \_ -> unused,+ Inspect.enabledInputs = \_ -> unused+ }+ catalog = Map.fromList [("z-alias", Inspect.SomeProcessManagerInspector descriptor), ("a-alias", Inspect.SomeProcessManagerInspector descriptor)]+ hooks = Ops.emptyAppHooks {Ops.processManagers = Just catalog}+ env = OpsEnv unusable HumanTable False [] False+ runStoreIO unusable (getStream (StreamName "sentinel")) `shouldThrow` anyIOException+ PM.validateCommand PM.Types `shouldBe` Right ()+ PM.isMutation PM.Types `shouldBe` False+ Succeeded result <- PM.runCommand catalog env PM.Types+ result.rows `shouldBe` [["a-alias", "opsreaction", "false", "never", "unknown"], ["z-alias", "opsreaction", "false", "never", "unknown"]]+ Text.length (renderHuman result) `shouldSatisfy` (> 0)+ Text.length (jsonText result.jsonValue) `shouldSatisfy` (> 0)+ case Options.execParserPure Options.defaultPrefs (Options.info (Ops.commandParser hooks) mempty) ["pm", "types"] of+ Options.Success command -> Ops.runCommand hooks env command `shouldReturn` Succeeded result+ _ -> expectationFailure "pm types did not parse"+ Succeeded empty <- PM.runCommand Map.empty env PM.Types+ empty.jsonValue `shouldBe` object ["items" .= ([] :: [Value])]+ cache <- newWorkflowHeaderCache 1048576 (const (fail "metadata used the header cache encoder"))+ calls <- newIORef (0 :: Int)+ let encodeDirect encoding = do+ modifyIORef' calls (+ 1)+ pure (Just (Lazy.toStrict (Encoding.encodingToLazyByteString encoding)))+ command = Ops.ProcessManager PM.Types+ (Succeeded prepared, Just bytes) <- Ops.runCommandWithReadEncoding encodeDirect cache hooks env command+ prepared `shouldBe` result+ Aeson.eitherDecodeStrict bytes `shouldBe` Right result.jsonValue+ _ <- Ops.runCommandWithReadEncoding encodeDirect cache hooks env command+ readIORef calls `shouldReturn` 2+ workflowHeaderCacheUsage cache `shouldReturn` (0, 0)+ (refused, body) <- Ops.runCommandWithReadEncoding (const (pure Nothing)) cache hooks env command+ refused `shouldSatisfy` (\case InspectionFailed "inspection_result_too_large" _ -> True; _ -> False)+ body `shouldBe` Nothing+ it "mounts a handwritten reaction inspector through pm show and pm list" $ \(store, StoreRunner run) -> do+ clock <- getCurrentTime+ let source = RecordedEvent (EventId UUID.nil) (EventType "Input") (StreamVersion 1) (GlobalPosition 1) (StreamId 1) (StreamVersion 1) Null Nothing Nothing Nothing clock+ timer = Timer.TimerRequest (Timer.TimerId UUID.nil) "modern" "one" clock (object [])+ manager =+ Reaction.ReactiveProcessManager+ "modern"+ id+ handler+ (\cid -> stream ("opsreaction-" <> cid))+ definition+ (const [])+ (const (Reaction.AdvanceReaction Go [] [Reaction.FollowSchedule Reaction.Once timer]))+ descriptor = Inspect.reactiveProcessManagerInspector manager (Stream.categoryUnsafe "opsreaction") (\state _ -> String (Text.pack (show state)))+ hooks = Ops.emptyAppHooks {Ops.processManagers = Just (Map.singleton "modern" (Inspect.SomeProcessManagerInspector descriptor))}+ invoke args = case Options.execParserPure Options.defaultPrefs (Options.info (Ops.commandParser hooks) mempty) args of+ Options.Success command ->+ Ops.runCommand hooks (OpsEnv store Json False [] False) command >>= \case+ Succeeded result -> pure result.jsonValue+ other -> fail (show other)+ _ -> fail "inspection command did not parse"+ Right (Right _) <- run (Reaction.runReactiveProcessManagerOnce defaultRunCommandOptions manager source "one")+ shown <- invoke ["pm", "show", "--name", "modern", "--correlation", "one"]+ field "state" shown `shouldBe` Just (String "Finished")+ field "state_version" shown `shouldBe` Just (Number 1)+ case field "last_reaction" shown of+ Just last -> do+ field "provenance_status" last `shouldBe` Just (String "unavailable")+ field "provenance" last `shouldBe` Just Null+ Nothing -> expectationFailure "accepted saga event is missing"+ case field "pending_timers" shown >>= field "items" of+ Just (Array items) -> length items `shouldBe` 1+ _ -> expectationFailure "reaction timer is missing"+ case field "waiting_for" shown of+ Just (Array items) -> length items `shouldBe` 1+ _ -> expectationFailure "live input candidate is missing"+ listed <- invoke ["pm", "list", "--name", "modern", "--limit", "1"]+ case field "items" listed of+ Just (Array rows) -> map (field "correlation_id") (foldr (:) [] rows) `shouldBe` [Just (String "one")]+ _ -> expectationFailure "reaction journal is missing from the manager catalog"++field :: Text -> Value -> Maybe Value+field key (Object fields) = KeyMap.lookup (Key.fromText key) fields+field _ _ = Nothing++data State = Ready | Finished deriving stock (Eq, Show, Ord, Enum, Bounded)++data Cmd = Go deriving stock (Eq, Show)++data Event = Observed deriving stock (Eq, Show)++definition :: ValidatedEventStream (HsPred '[] Cmd) '[] State Cmd Event+definition =+ mkEventStreamOrThrow+ "ops-reaction-inspection"+ EventStream+ { transducer =+ SymTransducer+ { edgesOut = const [Edge {guard = matchInCtor goCtor, update = UKeep, output = [pack goCtor eventCtor oNil], target = Finished, mode = Live}],+ initial = Ready,+ initialRegs = RNil,+ isFinal = const False+ },+ initialState = Ready,+ initialRegisters = RNil,+ eventCodec =+ Codec+ { eventTypes = EventType "Observed" :| [],+ eventType = const (EventType "Observed"),+ schemaVersion = 1,+ encode = const Null,+ decode = \kind _ -> if kind == EventType "Observed" then Right Observed else Left "unknown fixture event",+ upcasters = []+ },+ resolveStreamName = Stream.streamName,+ snapshotPolicy = Never,+ stateCodec = Nothing+ }++handler :: DomainCommandHandler (HsPred '[] Cmd) '[] State Cmd Event Text Text+handler = DomainCommandHandler definition (const (SilentNoOp "unused"))++goCtor :: InCtor Cmd '[]+goCtor = unavailableInCtor "Go" (const (Just RNil)) (\RNil -> Go)++eventCtor :: WireCtor Event ()+eventCtor = unavailableWireCtor "Observed" (const (Just ())) (const Observed)