Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 46 additions & 23 deletions hls-graph/src/Development/IDE/Graph/Internal/Database.hs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,46 @@ newDatabase databaseExtra databaseRules = do
databaseValues <- atomically SMap.new
pure Database{..}

{- Note [Invalidation, Step Counter, and Stale Running States]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
hls-graph implements an in-memory, lock-less build graph designed for reactive
builds and rapid cancellation/restart (e.g. when new LSP edits arrive).

Invalidation Architecture:
-----------------------------
Invalidation operates through two complementary mechanisms:

a) Eager Invalidation ('incDatabase'):
When starting a build step, 'incDatabase' increments 'databaseStep' by 1.
If a subset of modified keys is supplied, 'transitiveDirtySet'
traverses the reverse dependency graph ('keyReverseDeps') and sets every
downstream key's status to 'Dirty' via 'updateDirty'.

b) Lazy Invalidation ('viewDirty' and 'databaseStep'):
When a build session is interrupted, in-flight worker threads are aborted.
Interrupted keys are simply left in the 'Running' state.
When the next session starts, 'incDatabase' incre 'databaseStep'.
Any subsequent access to the key via 'builder' goes through 'viewDirty',
'viewDirty' automatically treats the stale 'Running' with old step as Dirty.

Invariants:
--------------
* [Running Step Match]:
A key is actively running in the current build if and only if its status is
'Running' and 'runningStep == databaseStep'. Any 'Running' node with
'runningStep /= databaseStep' represents an aborted/stale run and is
semantically 'Dirty'.
* [Single Active Spawner]:
Within any single build step s, at most one thread creates a 'Spawn' for a
given key. Any subsequent requests in the same step register a 'Wait'.
* [Safe Interruption / Zero-Cost Cancellation]:
Cancelling a build session requires no rollback or cleanup in 'databaseValues'.
Stale 'Running' states are lazily and safely neutralized by the step increment.
-}

-- | Increment the step and mark dirty.
-- Assumes that the database is not running a build
-- See Note [Invalidation, Step Counter, and Stale Running States]
incDatabase :: Database -> Maybe [Key] -> IO ()
-- only some keys are dirty
incDatabase db (Just kk) = do
Expand Down Expand Up @@ -102,6 +140,7 @@ build db stack keys = do
-- | Build a list of keys and return their results.
-- If none of the keys are dirty, we can return the results immediately.
-- Otherwise, a blocking computation is returned *which must be evaluated asynchronously* to avoid deadlock.
-- See Note [Invalidation, Step Counter, and Stale Running States]
builder
:: Traversable f => Database -> Stack -> f Key -> AIO (Either (f (Key, Result)) (IO (f (Key, Result))))
-- builder _ st kk | traceShow ("builder", st,kk) False = undefined
Expand All @@ -124,7 +163,7 @@ builder db@Database{..} stack keys = withRunInIO $ \(RunInIO run) -> do
pure val
Dirty s -> do
let act = run (refresh db stack id s)
(force, val) = splitIO (join act)
(force, val) = splitIO act
SMap.focus (updateStatus $ Running current force val s) id databaseValues
modifyTVar' toForce (Spawn force:)
pure val
Expand All @@ -151,7 +190,8 @@ isDirty me = any (\(_,dep) -> resultBuilt me < resultChanged dep)
-- and shortcut the refreshing of the rest of the deps.
-- * If no dirty dependencies and we have evaluated the key previously, then we refresh it in the current thread.
-- This assumes that the implementation will be a lookup
-- * Otherwise, we spawn a new thread to refresh the dirty deps (if any) and the key itself
-- * Otherwise, new threads would be created to refresh the dirty deps (if any) and
-- then compute the key itself in current thread
refreshDeps :: KeySet -> Database -> Stack -> Key -> Result -> [KeySet] -> AIO Result
refreshDeps visited db stack key result = \case
-- no more deps to refresh
Expand All @@ -171,14 +211,14 @@ refreshDeps visited db stack key result = \case
then liftIO $ compute db stack key RunDependenciesChanged (Just result)
else refreshDeps newVisited db stack key result deps

-- | Refresh a key:
refresh :: Database -> Stack -> Key -> Maybe Result -> AIO (IO Result)
-- | Refresh a key in the existing force runner, which already owns its lifetime.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you elaborate this comment a bit, maybe write a Note about the ownership model?

See if you agree with how I read this currently:

  • A force runner refers to the first thread that evaluates the rule thunk in the database.
  • Refreshing refers to checking whether any dependencies changed and the key needs to be recomputed.
  • If a session restart occurs while a thread is refreshing a key, this is safe as the shake database increment invalidates outdated values as well.

@soulomoon soulomoon Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. By design, builder find out the rule is still Dirty and wrap it into Spawn(If Running, just wrap it into Wait) and then waitConcurrently_ create its force runner and run it.
  2. yes
  3. If session restart interrupt the proccess, since the step incre and the next run would consider it as Dirty again even though the Runningstate would not be altered. see viewDirty.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have an existing note that captures this? If not, could you write one that explains how invalidation is structured and the invariants that hold?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added. feel free to update the note if there is any problem there

refresh :: Database -> Stack -> Key -> Maybe Result -> AIO Result
-- refresh _ st k _ | traceShow ("refresh", st, k) False = undefined
refresh db stack key result = case (addStack key stack, result) of
(Left e, _) -> throw e
(Right stack, Just me@Result{resultDeps = ResultDeps deps}) -> asyncWithCleanUp $ refreshDeps mempty db stack key me (reverse deps)
(Right stack, Just me@Result{resultDeps = ResultDeps deps}) -> refreshDeps mempty db stack key me (reverse deps)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above refreshDeps still refers to this spawn, mind updating the comment?

@soulomoon soulomoon Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The builder would create a new thread for its deps ?

perhaps change it to Otherwise, new threads would be created to refresh the dirty deps (if any) and then compute the key itself in current thread ? WDYT

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

(Right stack, _) ->
asyncWithCleanUp $ liftIO $ compute db stack key RunDependenciesChanged result
liftIO $ compute db stack key RunDependenciesChanged result

-- | Compute a key.
compute :: Database -> Stack -> Key -> RunMode -> Maybe Result -> IO Result
Expand Down Expand Up @@ -310,23 +350,6 @@ runAIO (AIO act) = do
asyncs <- newIORef []
runReaderT act asyncs `onException` cleanupAsync asyncs

-- | Like 'async' but with built-in cancellation.
-- Returns an IO action to wait on the result.
asyncWithCleanUp :: AIO a -> AIO (IO a)
asyncWithCleanUp act = do
st <- AIO ask
io <- unliftAIO act
-- mask to make sure we keep track of the spawned async
liftIO $ uninterruptibleMask $ \restore -> do
a <- async $ restore io
atomicModifyIORef'_ st (void a :)
return $ wait a

unliftAIO :: AIO a -> AIO (IO a)
unliftAIO act = do
st <- AIO ask
return $ runReaderT (unAIO act) st

newtype RunInIO = RunInIO (forall a. AIO a -> IO a)

withRunInIO :: (RunInIO -> AIO b) -> AIO b
Expand Down
3 changes: 3 additions & 0 deletions hls-graph/src/Development/IDE/Graph/Internal/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ data Status
runningPrev :: !(Maybe Result)
}

-- | Map running states from previous steps to 'Dirty'.
-- See Note [Invalidation, Step Counter, and Stale Running States]
-- in Development.IDE.Graph.Internal.Database.
viewDirty :: Step -> Status -> Status
viewDirty currentStep (Running s _ _ re) | currentStep /= s = Dirty re
viewDirty _ other = other
Expand Down
Loading