heni-0.1.0.0: test/scheduler/Main.hs
module Main (main) where
import Control.Concurrent
import Control.Exception
import Control.Monad (unless)
import Data.ByteString qualified as BS
import Data.IORef
import Data.List (sort)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding
import System.Directory qualified as Dir
import System.Environment (lookupEnv, setEnv, unsetEnv)
import System.FilePath ((</>))
import System.Posix.Files (setFileTimes)
import System.Posix.Temp (mkdtemp)
import System.Timeout (timeout)
import Test.Heni
import Test.Heni.BuildReuse qualified as Build
import Test.Heni.Resource qualified as R
import Test.Heni.Scheduler (runScheduled)
import Test.Heni.Workspace
assert' :: String -> Bool -> IO ()
assert' why good = unless good (error why)
checked :: (Show e) => Either e a -> a
checked = either (error . show) id
modify_ :: IORef a -> (a -> a) -> IO ()
modify_ ref f = atomicModifyIORef' ref (\a -> (f a, ()))
main :: IO ()
main = timeout 120000000 checks >>= \case Nothing -> error "scheduler watchdog"; Just () -> pure ()
checks :: IO ()
checks = bracket (Dir.getTemporaryDirectory >>= \p -> mkdtemp (p </> "heni-scheduler-")) Dir.removePathForcibly $ \root -> do
let source = root </> "source"
Dir.createDirectory source
writeFile (source </> "input") "anchor"
lifecycle <- newIORef []
let binding = R.ResourceBinding "fixture" "v1" (Just "clean-1") [("HENI_RESOURCE_DB_TOKEN", "secret-not-reportable")]
makeProvider name scope failAcquire failRelease =
R.managedResource
name
scope
(if name == "WORKER" then 2 else 1)
(\_ -> do modify_ lifecycle (++ [name <> "+"]); if failAcquire then ioError (userError "setup") else pure ((), if name == "DB" then binding else R.ResourceBinding "other" "v1" (Just "clean-1") []))
(\() -> modify_ lifecycle (++ [name <> "="]) >> pure (if name == "DB" then binding else R.ResourceBinding "other" "v1" (Just "clean-1") []))
(\() -> do modify_ lifecycle (++ [name <> "-"]); if failRelease then ioError (userError "cleanup") else pure ())
db = makeProvider "DB" R.CaseScope False False
withSnapshot source defaultSnapshotPolicy $ \snapshot -> do
let context = R.ResourceContext "test" snapshot root (const (pure ()))
R.withResources [db, makeProvider "SECOND" R.CaseScope False False] context $ \leases -> mapM_ (R.prepareResource . snd) leases
events <- readIORef lifecycle
assert' "declaration acquisition/reverse release" (events == ["DB+", "SECOND+", "DB=", "SECOND=", "SECOND-", "DB-"])
writeIORef lifecycle []
partial <- try (R.withResources [db, makeProvider "SECOND" R.CaseScope True False] context (const (pure ()))) :: IO (Either R.ResourceError ())
assert' "partial acquisition reports setup" (case partial of Left _ -> True; _ -> False)
events' <- readIORef lifecycle
assert' "partial acquisition releases earlier ownership" (events' == ["DB+", "SECOND+", "DB-"])
both <- try (R.withResources [makeProvider "DB" R.CaseScope False True] context (const (ioError (userError "primary")))) :: IO (Either WorkspaceFinalizationError ())
assert' "primary plus cleanup errors preserved" (case both of Left (WorkspaceFinalizationError (Just _) _) -> True; _ -> False)
withSnapshot source defaultSnapshotPolicy $ \snapshot -> do
writeIORef lifecycle []
timed <- timeout 10000 (R.withResources [db] (R.ResourceContext "timed" snapshot root (const (pure ()))) (const (threadDelay 1000000)))
releasedAfterTimeout <- readIORef lifecycle
assert' "timeout releases acquired callback resources" (timed == Nothing && releasedAfterTimeout == ["DB+", "DB-"])
usage <- newIORef (0 :: Int, 0 :: Int)
completed <- newIORef []
let doWork _ item = bracket_ (modify_ usage (\(n, peak) -> (n + 1, max peak (n + 1)))) (modify_ usage (\(n, peak) -> (n - 1, peak))) (threadDelay 30000 >> modify_ completed (++ [item]))
runScheduled 3 (Map.fromList [("A", 1), ("B", 1)]) [(1 :: Int, ["A", "B"]), (2, ["B", "A"]), (3, ["A"])] (\_ action -> action) doWork
(_, peak) <- readIORef usage
assert' "atomic opposite-order claims never overlap" (peak == 1)
done <- readIORef completed
assert' "finite queue completes exactly once" (sort done == [1, 2, 3])
entered <- newMVar (0 :: Int)
barrier <- newEmptyMVar
runScheduled
2
Map.empty
[(1 :: Int, []), (2, [])]
(\_ action -> action)
( \_ _ -> do
n <- modifyMVar entered (\v -> pure (v + 1, v + 1))
if n == 2 then putMVar barrier () else pure ()
readMVar barrier
)
let bid = checked (mkCommandId "build")
tid = checked (mkCommandId "test")
ids = map (checked . mkCaseId) ["first", "second", "queued"]
command args = Command "heni-fixture" args Set.empty Nothing [] (checked (mkStageBudget 5000000)) (OutputPolicy 1024)
definition cid = CaseDefinition cid "resource scheduling" Set.empty [Replace (checked (mkRelativeTarget "input")) "anchor" "mutant" 1] bid (Just tid) Nothing (Diagnostic Test 1 "capacity rule violated") GenericCommands
campaign = checked (mkCampaign (Map.fromList [(bid, command ["exit", "0"]), (tid, command ["check", "input", "diagnostic"])]) [(definition cid, noHooks) | cid <- ids])
claims = Map.fromList [(cid, ["DB"]) | cid <- ids]
paths <- newIORef []
let options name workers =
(defaultRunOptions source (root </> name))
{ workers = workers,
resources = [db],
resourceClaims = claims,
workspaceCleanup = \workspace -> modify_ paths (++ [workspaceRoot workspace])
}
serial <- runCampaign (options "serial" 1) campaign
parallel <- runCampaign (options "parallel" 3) campaign
let outcomes RunResult {results} = map (\CaseResult {caseId, verdict} -> (caseId, verdict)) results
assert' "serial/parallel verdict and ledger parity" (outcomes serial == outcomes parallel && all ((== Caught) . snd) (outcomes parallel))
roots <- readIORef paths
assert' "all baseline/candidate trees are exclusive" (length roots == 12 && Set.size (Set.fromList roots) == 12)
portable <- readReport (root </> "parallel/report.json")
assert' "parallel journal/report artifacts remain readable" (case portable of Right _ -> True; _ -> False)
writeIORef lifecycle []
let scopedProviders = [makeProvider "DB" R.CampaignScope False False, makeProvider "WORKER" R.WorkerScope False False, makeProvider "CASE" R.CaseScope False False]
_ <- runCampaign ((options "all-scopes" 2) {resources = scopedProviders, resourceClaims = Map.fromList [(cid, ["DB", "WORKER", "CASE"]) | cid <- ids]}) campaign
scopeEvents <- readIORef lifecycle
let occurrences event = length (filter (== event) scopeEvents)
assert' ("campaign/worker/case acquire and release lifetimes: " ++ show scopeEvents) (occurrences "DB+" == 1 && occurrences "DB-" == 1 && occurrences "WORKER+" == 2 && occurrences "WORKER-" == 2 && occurrences "CASE+" == 3 && occurrences "CASE-" == 3)
writeIORef lifecycle []
_ <- runCampaign ((options "worker-acquisition-capacity" 3) {resources = [makeProvider "DB" R.WorkerScope False False]}) campaign
workerEvents <- readIORef lifecycle
assert' "worker acquisition itself respects declared capacity" (length (filter (== "DB+") workerEvents) == 1 && length (filter (== "DB-") workerEvents) == 1)
baselineCount <- newIORef (0 :: Int)
let observeBaseline (StageFinished _ "baseline" StageEvidence {stage = Test}) = modify_ baselineCount (+ 1)
observeBaseline _ = pure ()
reuseOptions name provider count = (options name count) {resources = [provider], reusePolicy = ReuseBaselines "fixture-toolchain/v1" Nothing, progress = observeBaseline}
runCount name provider count chosen = do
writeIORef baselineCount 0
result <- runCampaign (reuseOptions name provider count) chosen
invocations <- readIORef baselineCount
pure (result, invocations)
(reused, count) <- runCount "reuse" db 3 campaign
assert' "compatible baselines execute exactly once across workers" (count == 1 && outcomes reused == outcomes parallel)
originalReuseReport <- BS.readFile (root </> "reuse/report.json")
BS.writeFile (root </> "reuse/report.json") (Encoding.encodeUtf8 (Text.replace "\"ownerCase\":\"first\"" "\"ownerCase\":\"absent\"" (Encoding.decodeUtf8 originalReuseReport)))
badOwner <- readReport (root </> "reuse/report.json")
assert' "reuse provenance cannot reference absent producer evidence" (case badOwner of Left _ -> True; _ -> False)
BS.writeFile (root </> "reuse/report.json") originalReuseReport
let bid2 = checked (mkCommandId "different-build")
alter cid select buildId = case definition cid of CaseDefinition i d ts es _ t _ e mode -> CaseDefinition i d ts es buildId t select e mode
varied =
checked
( mkCampaign
(Map.fromList [(bid, command ["exit", "0"]), (bid2, command ["echo", "different build option"]), (tid, command ["check", "input", "diagnostic"])])
[(alter (ids !! 0) Nothing bid, noHooks), (alter (ids !! 1) (Just "different selection") bid, noHooks), (alter (ids !! 2) Nothing bid2, noHooks)]
)
(_, differentCount) <- runCount "different-contracts" db 1 varied
assert' "selector and command/build changes invalidate sharing" (differentCount == 3)
let unknown = R.managedResource "DB" R.CaseScope 1 (\_ -> pure ((), R.ResourceBinding "fixture" "v1" Nothing [])) (\() -> pure (R.ResourceBinding "fixture" "v1" Nothing [])) (\() -> pure ())
(_, unknownCount) <- runCount "unknown-epoch" unknown 2 campaign
assert' "unknown external state disables reuse" (unknownCount == 3)
epochCounter <- newIORef (0 :: Int)
let changing =
R.managedResource
"DB"
R.CaseScope
1
( \_ -> do
n <- atomicModifyIORef' epochCounter (\v -> (v + 1, v + 1))
let b = R.ResourceBinding "fixture" "v1" (Just (Text.pack (show n))) []
pure (b, b)
)
pure
(const (pure ()))
(_, epochCount) <- runCount "changed-epoch" changing 1 campaign
assert' "changed resource epoch invalidates sharing" (epochCount == 3)
cleanupCount <- newIORef (0 :: Int)
writeIORef baselineCount 0
resetFailed <-
runCampaign
( (reuseOptions "cleanup-invalidates" db 1)
{ workspaceCleanup = \_ -> do
n <- atomicModifyIORef' cleanupCount (\v -> (v + 1, v + 1)); if n == 2 then ioError (userError "failed reset") else pure ()
}
)
campaign
afterFailure <- readIORef baselineCount
assert' "failed cleanup retires cached evidence; next case is fresh" (afterFailure == 2 && case resetFailed of RunResult {results = CaseResult {verdict = InfrastructureError} : CaseResult {verdict = Caught} : _} -> True; _ -> False)
secretReport <- BS.readFile (root </> "reuse/report.json")
assert' "resource values and secret comparison never enter reports" (not ("secret-not-reportable" `BS.isInfixOf` secretReport))
finishedEvents <- newIORef []
let brokenTeardown = makeProvider "DB" R.CaseScope False True
observeFinished (CaseFinished CaseResult {caseId, verdict}) = modify_ finishedEvents (++ [(caseId, verdict)])
observeFinished _ = pure ()
releasedRun <- runCampaign ((options "final-event-after-release" 2) {resources = [brokenTeardown], progress = observeFinished}) campaign
finalEvents <- readIORef finishedEvents
assert' "one final progress event after failing case teardown" (sort finalEvents == sort (outcomes releasedRun) && length finalEvents == 3 && all ((== InfrastructureError) . snd) finalEvents)
priorEnv <- lookupEnv "HENI_ENV_CONFORMANCE"
let restoreEnv = maybe (unsetEnv "HENI_ENV_CONFORMANCE") (setEnv "HENI_ENV_CONFORMANCE") priorEnv
( do
setEnv "HENI_ENV_CONFORMANCE" "first-state"
writeIORef baselineCount 0
let envObserver event = do
observeBaseline event
case event of CaseFinished {} -> setEnv "HENI_ENV_CONFORMANCE" "second-state"; _ -> pure ()
_ <- runCampaign ((reuseOptions "environment-change" db 1) {progress = envObserver}) campaign
envCount <- readIORef baselineCount
assert' "inherited environment contract is compared in memory" (envCount == 2)
)
`finally` restoreEnv
writeFile (source </> "changed-test-or-fixture") "changed input"
(_, newRunCount) <- runCount "new-snapshot" db 1 campaign
assert' "a new campaign with changed inputs starts without a verdict cache" (newRunCount == 1)
Dir.removeFile (source </> "changed-test-or-fixture")
buildRoots <- newIORef []
let buildProvider =
Build.BuildReuse
"content-key-fixture/v1"
[checked (mkRelativeTarget "dist-newstyle")]
(\workspace -> setFileTimes (workspaceRoot workspace </> "input") 0 0)
( \workspace expected -> do
observed <- Text.pack <$> readFile (workspaceRoot workspace </> "dist-newstyle/observed-key")
pure (if observed == expected then Right () else Left "build did not observe restored/mutated input key")
)
alternating mode =
checked
( mkCampaign
(Map.fromList [(bid, command ["incremental-build", mode]), (tid, command ["incremental-test"])])
[(case definition cid of CaseDefinition i d ts _ b t sel e m -> CaseDefinition i d ts [Replace (checked (mkRelativeTarget "input")) "anchor" changed 1] b t sel e m, noHooks) | (cid, changed) <- zip ids ["mutant", "safe__", "mutant"]]
)
buildOptions name provider = (defaultRunOptions source (root </> name)) {buildReuse = Just provider, workspaceCleanup = \workspace -> modify_ buildRoots (++ [workspaceRoot workspace])}
incremental <- runCampaign (buildOptions "incremental" buildProvider) (alternating "safe")
freshAlternating <- runCampaign (defaultRunOptions source (root </> "fresh-alternating")) (alternating "safe")
assert' "same-size alternating edits with identical coarse mtimes preserve fresh outcomes" (outcomes incremental == outcomes freshAlternating && map snd (outcomes incremental) == [Caught, Survived, Caught])
seenRoots <- readIORef buildRoots
assert' "one private incremental candidate tree, separate fresh baselines" (Set.size (Set.fromList seenRoots) == 4)
unsafe <- runCampaign (buildOptions "unsafe-invalidation" buildProvider) (alternating "unsafe")
assert' "stale build observations are infrastructure, never detection" (case unsafe of RunResult {results = [CaseResult {verdict = Caught}, CaseResult {verdict = InfrastructureError}, CaseResult {verdict = Caught}]} -> True; _ -> False)
let rejected =
checked
( mkCampaign
(campaignCommands (alternating "reject"))
[(case d of CaseDefinition i desc tags edits b t sel _ mode -> CaseDefinition i desc tags edits b t sel (BuildRejection 1 "intended-build-rejection") mode, noHooks) | d <- campaignCases (alternating "reject")]
)
badObservation = case buildProvider of Build.BuildReuse contract dirs prep _ -> Build.BuildReuse contract dirs prep (\_ _ -> pure (Left "unproved compiler input"))
verifiedRejection <- runCampaign (buildOptions "verified-build-rejection" buildProvider) rejected
assert' "verified retained compiler rejection keeps expected-build meaning" (map snd (outcomes verifiedRejection) == [Caught, Survived, Caught])
rejectedBuild <- runCampaign (buildOptions "incremental-build-rejection" badObservation) rejected
assert' "expected compiler rejection also requires observed input identity" (all ((== InfrastructureError) . snd) (outcomes rejectedBuild))
let unexpected = case buildProvider of Build.BuildReuse contract dirs prep verify -> Build.BuildReuse contract dirs (\workspace -> prep workspace >> writeFile (workspaceRoot workspace </> "unexpected-generated") "bad") verify
generated <- runCampaign (buildOptions "generated-input" unexpected) (alternating "safe")
assert' "undeclared generated state invalidates retained worker" (all ((== InfrastructureError) . snd) (outcomes generated))
acquired <- newIORef (0 :: Int)
released <- newIORef (0 :: Int)
ready <- newEmptyMVar
enteredCount <- newIORef (0 :: Int)
let held = R.managedResource "HELD" R.CaseScope 2 (\_ -> modify_ acquired (+ 1) >> pure ((), R.ResourceBinding "held" "v1" Nothing [])) (\() -> pure (R.ResourceBinding "held" "v1" Nothing [])) (\() -> modify_ released (+ 1))
waitCampaign = checked (mkCampaign (Map.fromList [(bid, command ["sleep"]), (tid, command ["exit", "0"])]) [(definition cid, noHooks) | cid <- ids])
observe (CaseStarted _) = do n <- atomicModifyIORef' enteredCount (\v -> (v + 1, v + 1)); if n == 2 then putMVar ready () else pure ()
observe _ = pure ()
finished <- newEmptyMVar
thread <- forkFinally (runCampaign ((defaultRunOptions source (root </> "cancel")) {workers = 2, resources = [held], resourceClaims = Map.fromList [(cid, ["HELD"]) | cid <- ids], progress = observe}) waitCampaign) (putMVar finished)
takeMVar ready
throwTo thread ThreadKilled
cancelled <- takeMVar finished
let wasCancelled = case cancelled of
Left e -> case fromException e :: Maybe AsyncException of
Just ThreadKilled -> True
_ -> False
Right _ -> False
assert' "cancellation rethrows" wasCancelled
a <- readIORef acquired
r <- readIORef released
assert' "all acquired resources released on cancellation" (a == 2 && r == a)
partialReport <- readReport (root </> "cancel/report.json")
assert' "queued and running IDs finalized exactly once" (case partialReport of Right StoredReport {result = RunResult {results, status}} -> length results == 3 && status == RunInterrupted; _ -> False)
putStrLn "Scoped ownership, atomic capacities, isolated workers, parity and cancellation passed"