Skip to content

Commit 42382e0

Browse files
committed
Discard rule results from runs superseded by a restart
1 parent ee9614b commit 42382e0

3 files changed

Lines changed: 143 additions & 4 deletions

File tree

hls-graph/src/Development/IDE/Graph/Internal/Database.hs

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Control.Concurrent.Extra
1717
import Control.Concurrent.STM.Stats (STM, atomically,
1818
atomicallyNamed,
1919
modifyTVar', newTVarIO,
20-
readTVarIO)
20+
readTVar, readTVarIO)
2121
import Control.Exception
2222
import Control.Monad
2323
import Control.Monad.IO.Class (MonadIO (liftIO))
@@ -180,10 +180,40 @@ refresh db stack key result = case (addStack key stack, result) of
180180
(Right stack, _) ->
181181
asyncWithCleanUp $ liftIO $ compute db stack key RunDependenciesChanged result
182182

183+
{- Note [Discard superseded computations]
184+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
185+
A restart cancels the running build with an async exception, but the computation
186+
it spawned can outlive it and finish under a later build. 'compute' stamps the
187+
result with whatever the step is *now*, so a value read from stale inputs is
188+
recorded as freshly built and dependents skip recomputing it:
189+
190+
step 1 build A starts rule R, reading its inputs at step 1
191+
step 2 a restart bumps the step to 2 and marks R dirty
192+
... A finally finishes and, unguarded, commits Clean@2,
193+
a stale result, but its timestamp claims it is fresh
194+
195+
Guard: 'compute' samples the step into 'startStep' before running and re-reads it
196+
before storing.
197+
198+
startStep == now : commit Clean, the normal path
199+
startStep < now : R was superseded. Mark the key Dirty, keeping the prior
200+
result as payload (as a restart does) so the next build
201+
recomputes it and can still cut off.
202+
203+
A newer build may already own the slot, so demoting blindly would drop its
204+
in-flight result or dirty its fresh one into a duplicate run. Take care not to
205+
clobber validly refreshed results:
206+
207+
Running s : keep if s > startStep
208+
Clean r : keep if resultVisited r > startStep
209+
-}
210+
183211
-- | Compute a key.
184212
compute :: Database -> Stack -> Key -> RunMode -> Maybe Result -> IO Result
185213
-- compute _ st k _ _ | traceShow ("compute", st, k) False = undefined
186214
compute db@Database{..} stack key mode result = do
215+
-- See Note [Discard superseded computations]
216+
startStep <- readTVarIO databaseStep
187217
let act = runRule databaseRules key (fmap resultData result) mode
188218
deps <- newIORef UnknownDeps
189219
(execution, RunResult{..}) <-
@@ -218,15 +248,30 @@ compute db@Database{..} stack key mode result = do
218248
deps
219249
_ -> pure ()
220250
atomicallyNamed "compute and run hook" $ do
221-
runHook
222-
SMap.focus (updateStatus $ Clean res) key databaseValues
251+
-- See Note [Discard superseded computations]
252+
stepNow <- readTVar databaseStep
253+
if stepNow == startStep
254+
then runHook >> SMap.focus (updateStatus $ Clean res) key databaseValues
255+
else SMap.focus (demoteSuperseded startStep) key databaseValues
223256
pure res
224257

225258
updateStatus :: Monad m => Status -> Focus.Focus KeyDetails m ()
226259
updateStatus res = Focus.alter
227260
(Just . maybe (KeyDetails res mempty)
228261
(\it -> it{keyStatus = res}))
229262

263+
-- | Demote a superseded key to Dirty unless a newer build already wrote a result.
264+
-- See Note [Discard superseded computations].
265+
demoteSuperseded :: Monad m => Step -> Focus.Focus KeyDetails m ()
266+
demoteSuperseded startStep = Focus.adjust $ \kd ->
267+
let st = keyStatus kd
268+
in if newerOwns st then kd else kd{keyStatus = Dirty (getResult st)}
269+
where
270+
newerOwns (Running s _ _ _) = s > startStep
271+
-- resultVisited, since `ChangedNothing` backdates `resultBuilt` time steps.
272+
newerOwns (Clean r) = resultVisited r > startStep
273+
newerOwns _ = False
274+
230275
-- | Returns the set of dirty keys annotated with their age (in # of builds)
231276
getDirtySet :: Database -> IO [(Key, Int)]
232277
getDirtySet db = do

hls-graph/src/Development/IDE/Graph/Internal/Types.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ data Result = Result {
152152
resultValue :: !Value,
153153
resultBuilt :: !Step, -- ^ the step when it was last recomputed
154154
resultChanged :: !Step, -- ^ the step when it last changed
155-
resultVisited :: !Step, -- ^ the step when it was last looked up
155+
resultVisited :: !Step, -- ^ the step when it was last looked up/produced the result.
156156
resultDeps :: !ResultDeps,
157157
resultExecution :: !Seconds, -- ^ How long it took, last time it ran
158158
resultData :: !BS.ByteString

hls-graph/test/ActionSpec.hs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,97 @@ spec = do
129129
res `shouldBe` [[True]]
130130
Just (Clean res) <- lookup (newKey theKey) <$> getDatabaseValues theDb
131131
resultDeps res `shouldBe` UnknownDeps
132+
133+
describe "Discard superseded computations" $ do
134+
it "leaves a key dirty when a restart bumps the step mid-computation" $ do
135+
started <- C.newEmptyMVar
136+
proceed <- C.newEmptyMVar
137+
done <- C.newEmptyMVar
138+
db@(ShakeDatabase _ _ theDb) <- shakeNewDatabase shakeOptions $
139+
addRule $ \(Rule :: Rule ()) _old _mode -> do
140+
liftIO $ C.putMVar started ()
141+
liftIO $ C.takeMVar proceed
142+
return $ RunResult ChangedRecomputeDiff "" () (return ())
143+
-- Fork so a restart can bump the step while the rule is still computing.
144+
_ <- C.forkIO $ shakeRunDatabase db [apply1 (Rule @())] >>= C.putMVar done
145+
C.takeMVar started
146+
-- Bumps the step without dirtying anything, so only the guard can leave
147+
-- this key dirty.
148+
incDatabase theDb (Just [])
149+
C.putMVar proceed ()
150+
_ <- C.takeMVar done
151+
Just status <- lookup (newKey (Rule @())) <$> getDatabaseValues theDb
152+
case status of
153+
Dirty{} -> pure ()
154+
Clean{} -> expectationFailure "superseded computation was committed clean"
155+
Running{} -> expectationFailure "superseded computation left running"
156+
it "commits clean when the step does not advance" $ do
157+
db@(ShakeDatabase _ _ theDb) <- shakeNewDatabase shakeOptions $
158+
addRule $ \(Rule :: Rule ()) _old _mode ->
159+
return $ RunResult ChangedRecomputeDiff "" () (return ())
160+
_ <- shakeRunDatabase db [apply1 (Rule @())]
161+
Just status <- lookup (newKey (Rule @())) <$> getDatabaseValues theDb
162+
case status of
163+
Clean{} -> pure ()
164+
_ -> expectationFailure "expected a clean commit"
165+
it "leaves a newer build's Running intact instead of stomping it" $
166+
withSupersededRespawn $ \theDb proceedA doneA proceedB doneB -> do
167+
-- A finishes while B is still Running{2}. Guard must keep B's Running.
168+
C.putMVar proceedA ()
169+
_ <- C.takeMVar doneA
170+
status <- lookup (newKey (Rule @())) <$> getDatabaseValues theDb
171+
-- Release B before asserting so its thread finishes.
172+
C.putMVar proceedB ()
173+
_ <- C.takeMVar doneB
174+
case status of
175+
Just Running{} -> pure ()
176+
Just Dirty{} -> expectationFailure "superseded build unnecessary marked dirty"
177+
Just Clean{} -> expectationFailure "newer build committed too early"
178+
Nothing -> expectationFailure "key missing from the database"
179+
it "leaves a newer build's committed Clean intact instead of dirtying it" $
180+
withSupersededRespawn $ \theDb proceedA doneA proceedB doneB -> do
181+
-- B commits Clean{2} before A demotes. Guard must keep B's Clean.
182+
C.putMVar proceedB ()
183+
_ <- C.takeMVar doneB
184+
C.putMVar proceedA ()
185+
_ <- C.takeMVar doneA
186+
status <- lookup (newKey (Rule @())) <$> getDatabaseValues theDb
187+
case status of
188+
Just Clean{} -> pure ()
189+
Just Dirty{} -> expectationFailure "superseded build unnecessary marked dirty"
190+
Just Running{} -> expectationFailure "newer build didn't commit"
191+
Nothing -> expectationFailure "key missing from the database"
192+
where
193+
-- Two builds of the same key.
194+
--
195+
-- 1. A, the superseded build, runs at step 1.
196+
-- 2. B, the re-spawn, runs at step 2. B's shakeRunDatabase bumps the step
197+
-- and re-dirties A's in-flight key, so the rule runs again and leaves B
198+
-- Running{2}.
199+
--
200+
-- Both are started and blocked before the continuation runs. The
201+
-- continuation picks the release ordering that decides whether the guard
202+
-- meets B as Running or as Clean.
203+
withSupersededRespawn
204+
:: (Database -> MVar () -> MVar () -> MVar () -> MVar () -> IO ())
205+
-> IO ()
206+
withSupersededRespawn k = do
207+
calls <- newTVarIO (0 :: Int)
208+
startedA <- C.newEmptyMVar
209+
proceedA <- C.newEmptyMVar
210+
startedB <- C.newEmptyMVar
211+
proceedB <- C.newEmptyMVar
212+
doneA <- C.newEmptyMVar
213+
doneB <- C.newEmptyMVar
214+
db@(ShakeDatabase _ _ theDb) <- shakeNewDatabase shakeOptions $
215+
addRule $ \(Rule :: Rule ()) _old _mode -> do
216+
n <- liftIO $ atomically $ modifyTVar' calls (+1) >> readTVar calls
217+
liftIO $ if n == 1
218+
then C.putMVar startedA () >> C.takeMVar proceedA
219+
else C.putMVar startedB () >> C.takeMVar proceedB
220+
return $ RunResult ChangedRecomputeDiff "" () (return ())
221+
_ <- C.forkIO $ shakeRunDatabase db [apply1 (Rule @())] >> C.putMVar doneA ()
222+
C.takeMVar startedA
223+
_ <- C.forkIO $ shakeRunDatabase db [apply1 (Rule @())] >> C.putMVar doneB ()
224+
C.takeMVar startedB
225+
k theDb proceedA doneA proceedB doneB

0 commit comments

Comments
 (0)