Skip to content

Commit fa708f6

Browse files
authored
Fix transitive propagation of matrix jobs through already-compiled dependants (#770)
1 parent 9e34788 commit fa708f6

8 files changed

Lines changed: 513 additions & 126 deletions

File tree

app/src/App/Effect/Registry.purs

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -333,26 +333,13 @@ handle env = Cache.interpret _registryCache (Cache.handleMemory env.cacheRef) <<
333333
Log.debug $ "Successfully read metadata for " <> printedName <> " from path " <> path
334334
pure (Just metadata)
335335

336-
-- Should be used when the cache may not be valid. Reads the metadata from
337-
-- disk and replaces the cache with it.
338-
resetFromDisk = readMetadataFromDisk >>= case _ of
339-
Nothing -> do
340-
Log.debug $ "Did not find " <> printedName <> " in memory cache or local registry repo checkout."
341-
pure Nothing
342-
343-
Just metadata -> do
344-
Log.debug $ "Successfully read metadata for " <> printedName <> " from path " <> path
345-
Log.debug $ "Setting metadata cache to singleton entry (as cache was previously empty)."
346-
Cache.put _registryCache AllMetadata (Map.singleton name metadata)
347-
pure $ Just metadata
348-
349336
pull RegistryRepo >>= case _ of
350337
Left error ->
351338
Except.throw $ "Could not read metadata because the registry repo could not be checked: " <> error
352339

353340
Right Git.NoChange -> do
354341
Cache.get _registryCache AllMetadata >>= case _ of
355-
Nothing -> resetFromDisk
342+
Nothing -> readMetadataFromDisk
356343
Just allMetadata -> case Map.lookup name allMetadata of
357344
Nothing -> do
358345
Log.debug $ "Did not find " <> printedName <> " in memory cache, trying local registry checkout..."
@@ -372,7 +359,7 @@ handle env = Cache.interpret _registryCache (Cache.handleMemory env.cacheRef) <<
372359
Right Git.Changed -> do
373360
Log.info "Registry repo has changed, clearing metadata cache..."
374361
Cache.delete _registryCache AllMetadata
375-
resetFromDisk
362+
readMetadataFromDisk
376363

377364
WriteMetadata name metadata reply -> map (map reply) Except.runExcept do
378365
let printedName = PackageName.print name

app/src/App/Server/MatrixBuilder.purs

Lines changed: 84 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Registry.App.Prelude
1414

1515
import Data.Array as Array
1616
import Data.Array.NonEmpty as NonEmptyArray
17+
import Data.Foldable (elem, foldM)
1718
import Data.FoldableWithIndex (foldMapWithIndex)
1819
import Data.Map as Map
1920
import Data.Set as Set
@@ -182,80 +183,98 @@ type MatrixSolverResult =
182183
}
183184

184185
solveForAllCompilers :: forall r. MatrixSolverData -> Run (AFF + EXCEPT String + LOG + r) (Set MatrixSolverResult)
185-
solveForAllCompilers { compilerIndex, name, version, compiler, dependencies } = do
186+
solveForAllCompilers solverData@{ compiler } = do
186187
-- remove the compiler we tested with from the set of all of them
187188
compilers <- (Array.filter (_ /= compiler) <<< NonEmptyArray.toArray) <$> PursVersions.pursVersions
188-
newJobs <- for compilers \target -> do
189-
Log.debug $ "Trying compiler " <> Version.print target <> " for package " <> PackageName.print name
190-
case Solver.solveWithCompiler (Range.exact target) compilerIndex dependencies of
191-
Left solverErrors -> do
192-
Log.info $ "Failed to solve with compiler " <> Version.print target <> ": " <> PackageName.print name <> "@" <> Version.print version
193-
Log.debug $ "Solver errors:\n" <> foldMapWithIndex
194-
(\i error -> "[Error " <> show (i + 1) <> "]\n" <> Solver.printSolverError error <> "\n")
195-
solverErrors
196-
pure Nothing
197-
Right (Tuple solvedCompiler resolutions) -> case solvedCompiler == target of
198-
true -> do
199-
Log.debug $ "Solved with compiler " <> Version.print solvedCompiler
200-
pure $ Just { compiler: target, resolutions, name, version }
201-
false -> do
189+
newJobs <- for compilers \target ->
190+
trySolveForCompiler (solverData { compiler = target })
191+
pure $ Set.fromFoldable $ Array.catMaybes newJobs
192+
193+
solveDependantsForCompiler :: forall r. MatrixSolverData -> Run (EXCEPT String + LOG + REGISTRY + r) (Set MatrixSolverResult)
194+
solveDependantsForCompiler { compilerIndex, name, version, compiler } = do
195+
manifestIndex <- Registry.readAllManifests
196+
let seed = Tuple name version
197+
{ results, visited } <- go manifestIndex (Set.singleton seed) name version
198+
Log.info $ Array.fold
199+
[ "Cascade from "
200+
, PackageName.print name
201+
, "@"
202+
, Version.print version
203+
, ": "
204+
, show (Set.size results)
205+
, " enqueued out of "
206+
, show (Set.size visited - 1)
207+
, " dependants visited"
208+
]
209+
pure results
210+
where
211+
-- Recursively find packages to enqueue. Trivially this includes direct
212+
-- dependants, but we need more than that: when a direct dependant is already
213+
-- compatible with the target compiler, recurse down to its own dependants,
214+
-- and so on.
215+
-- This handles niche cases of transitive version-conflict cascades:
216+
-- if A depends on B which depends on C (wide range), and A's full plan forces
217+
-- C@new (because of other packages) but all versions of B already compiled
218+
-- against C@old, then - if we only propagated direct dependents - B will
219+
-- never be retriggered.
220+
-- With this recursive propagation, when C@new completes we cascade through
221+
-- B (already compiled) and reach A, allowing for a plan to resolve.
222+
go manifestIndex visited pkgName pkgVersion = do
223+
let dependentManifests = ManifestIndex.dependants manifestIndex pkgName pkgVersion
224+
foldM (processManifest manifestIndex) { visited, results: Set.empty } dependentManifests
225+
226+
processManifest manifestIndex acc (Manifest manifest) = do
227+
let pv = Tuple manifest.name manifest.version
228+
if Set.member pv acc.visited then
229+
pure acc
230+
else do
231+
let newVisited = Set.insert pv acc.visited
232+
Registry.readMetadata manifest.name >>= case _ of
233+
Nothing -> do
234+
Log.warn $ "No metadata for dependant " <> PackageName.print manifest.name <> ", skipping"
235+
pure { visited: newVisited, results: acc.results }
236+
Just metadata ->
237+
case Map.lookup manifest.version (un Metadata metadata).published of
238+
Nothing -> do
239+
Log.warn $ "Dependant " <> PackageName.print manifest.name <> "@" <> Version.print manifest.version <> " not in metadata.published, skipping"
240+
pure { visited: newVisited, results: acc.results }
241+
Just { compilers }
242+
| elem compiler compilers -> do
243+
-- Already has compiler: propagate through to find stranded packages
244+
sub <- go manifestIndex newVisited manifest.name manifest.version
245+
pure { visited: sub.visited, results: acc.results <> sub.results }
246+
| otherwise -> do
247+
result <- trySolveForCompiler { compilerIndex, compiler, name: manifest.name, version: manifest.version, dependencies: manifest.dependencies }
248+
pure case result of
249+
Nothing -> { visited: newVisited, results: acc.results }
250+
Just entry -> { visited: newVisited, results: Set.insert entry acc.results }
251+
252+
-- | Try to solve a package's dependencies for a specific compiler. Returns
253+
-- | the solver result if the produced build plan targets the expected compiler,
254+
-- | Nothing otherwise (solver failure or compiler mismatch).
255+
trySolveForCompiler :: forall r. MatrixSolverData -> Run (LOG + r) (Maybe MatrixSolverResult)
256+
trySolveForCompiler { compilerIndex, compiler, name, version, dependencies } = do
257+
Log.debug $ "Trying compiler " <> Version.print compiler <> " for package " <> PackageName.print name
258+
case Solver.solveWithCompiler (Range.exact compiler) compilerIndex dependencies of
259+
Left solverErrors -> do
260+
Log.info $ "Failed to solve with compiler " <> Version.print compiler <> ": " <> PackageName.print name <> "@" <> Version.print version
261+
Log.debug $ "Solver errors:\n" <> foldMapWithIndex
262+
(\i error -> "[Error " <> show (i + 1) <> "]\n" <> Solver.printSolverError error <> "\n")
263+
solverErrors
264+
pure Nothing
265+
Right (Tuple solvedCompiler resolutions)
266+
| solvedCompiler == compiler -> do
267+
Log.debug $ "Solved " <> PackageName.print name <> "@" <> Version.print version <> " with compiler " <> Version.print solvedCompiler
268+
pure $ Just { compiler, resolutions, name, version }
269+
| otherwise -> do
202270
Log.debug $ Array.fold
203271
[ "Produced a compiler-derived build plan that selects a compiler ("
204272
, Version.print solvedCompiler
205273
, ") that differs from the target compiler ("
206-
, Version.print target
274+
, Version.print compiler
207275
, ")."
208276
]
209277
pure Nothing
210-
pure $ Set.fromFoldable $ Array.catMaybes newJobs
211-
212-
solveDependantsForCompiler :: forall r. MatrixSolverData -> Run (EXCEPT String + LOG + REGISTRY + r) (Set MatrixSolverResult)
213-
solveDependantsForCompiler { compilerIndex, name, version, compiler } = do
214-
manifestIndex <- Registry.readAllManifests
215-
let dependentManifests = ManifestIndex.dependants manifestIndex name version
216-
newJobs <- for dependentManifests \(Manifest manifest) -> do
217-
-- We skip if this compiler is already in the package's metadata compilers
218-
-- list (meaning it was already successfully tested). Failed compilations
219-
-- are not recorded in metadata, but the DB deduplication in insertMatrixJob
220-
-- prevents re-enqueuing jobs that already exist.
221-
shouldAttemptToCompile <- Registry.readMetadata manifest.name >>= case _ of
222-
Nothing -> do
223-
Log.debug $ "Skipping " <> PackageName.print manifest.name <> "@" <> Version.print manifest.version <> ": no metadata found"
224-
pure false
225-
Just metadata -> do
226-
let
227-
result = case Map.lookup manifest.version (un Metadata metadata).published of
228-
Nothing -> false
229-
Just { compilers } -> all (_ /= compiler) compilers
230-
unless result do
231-
Log.debug $ "Skipping " <> PackageName.print manifest.name <> "@" <> Version.print manifest.version <> ": compiler " <> Version.print compiler <> " already tested or version not published"
232-
pure result
233-
case shouldAttemptToCompile of
234-
false -> pure Nothing
235-
true -> do
236-
-- if all good then run the solver
237-
Log.debug $ "Trying compiler " <> Version.print compiler <> " for package " <> PackageName.print manifest.name
238-
case Solver.solveWithCompiler (Range.exact compiler) compilerIndex manifest.dependencies of
239-
Left solverErrors -> do
240-
Log.info $ "Failed to solve with compiler " <> Version.print compiler <> ": " <> PackageName.print manifest.name <> "@" <> Version.print manifest.version
241-
Log.debug $ "Solver errors:\n" <> foldMapWithIndex
242-
(\i error -> "[Error " <> show (i + 1) <> "]\n" <> Solver.printSolverError error <> "\n")
243-
solverErrors
244-
pure Nothing
245-
Right (Tuple solvedCompiler resolutions) -> case compiler == solvedCompiler of
246-
true -> do
247-
Log.debug $ "Solved " <> PackageName.print manifest.name <> "@" <> Version.print manifest.version <> " with compiler " <> Version.print solvedCompiler
248-
pure $ Just { compiler, resolutions, name: manifest.name, version: manifest.version }
249-
false -> do
250-
Log.debug $ Array.fold
251-
[ "Produced a compiler-derived build plan that selects a compiler ("
252-
, Version.print solvedCompiler
253-
, ") that differs from the target compiler ("
254-
, Version.print compiler
255-
, ")."
256-
]
257-
pure Nothing
258-
pure $ Set.fromFoldable $ Array.catMaybes newJobs
259278

260279
checkIfNewCompiler :: forall r. Run (EXCEPT String + LOG + REGISTRY + AFF + r) (Maybe Version)
261280
checkIfNewCompiler = do

app/test/App/Effect/Registry.purs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
module Test.Registry.App.Effect.Registry (spec) where
2+
3+
import Registry.App.Prelude
4+
5+
import Data.Map as Map
6+
import Effect.Aff as Aff
7+
import Effect.Ref as Ref
8+
import Node.Path as Path
9+
import Registry.App.CLI.Git as Git
10+
import Registry.App.Effect.Cache as Cache
11+
import Registry.App.Effect.GitHub (GITHUB, GitHub)
12+
import Registry.App.Effect.GitHub as GitHub
13+
import Registry.App.Effect.Log (LOG, Log(..))
14+
import Registry.App.Effect.Log as Log
15+
import Registry.App.Effect.Registry (REGISTRY, RegistryEnv, WriteMode(..))
16+
import Registry.App.Effect.Registry as Registry
17+
import Registry.Foreign.FSExtra as FS.Extra
18+
import Registry.Foreign.Tmp as Tmp
19+
import Registry.Metadata (Metadata(..))
20+
import Registry.Metadata as Metadata
21+
import Registry.Test.Assert as Assert
22+
import Registry.Test.Fixtures (defaultHash, defaultLocation)
23+
import Registry.Test.Utils (unsafeDateTime, unsafeNonEmptyArray, unsafePackageName, unsafeVersion)
24+
import Run (AFF, EFFECT, Run)
25+
import Run as Run
26+
import Run.Except (EXCEPT)
27+
import Run.Except as Except
28+
import Test.Spec as Spec
29+
30+
spec :: Spec.Spec Unit
31+
spec = do
32+
-- This test exercises the Registry.handle to verify that readMetadata does
33+
-- not poison the AllMetadata cache: i.e. a single-package read must not seed
34+
-- the cache with a singleton map that readAllMetadata would mistake for the
35+
-- complete set.
36+
Spec.it "readMetadata does not poison AllMetadata cache for readAllMetadata" do
37+
Aff.bracket Tmp.mkTmpDir FS.Extra.remove \tmp -> do
38+
let metadataDir = Path.concat [ tmp, "registry", "metadata" ]
39+
FS.Extra.ensureDirectory metadataDir
40+
41+
-- Write 3 metadata files to disk
42+
for_ packages \{ name, version, compilers } -> do
43+
let
44+
metadata = Metadata
45+
{ location: defaultLocation
46+
, owners: Nothing
47+
, published: Map.singleton (unsafeVersion version)
48+
{ bytes: 1000.0
49+
, compilers: unsafeNonEmptyArray (map unsafeVersion compilers)
50+
, hash: defaultHash
51+
, publishedTime: unsafeDateTime "2024-01-01T00:00:00.000Z"
52+
, ref: Nothing
53+
}
54+
, unpublished: Map.empty
55+
}
56+
liftAff $ writeJsonFile Metadata.codec (Path.concat [ metadataDir, name <> ".json" ]) metadata
57+
58+
-- Set up the RegistryEnv with a pre-populated debouncer so pull
59+
-- returns NoChange without doing any git operations.
60+
now <- nowUTC
61+
let registryPath = Path.concat [ tmp, "registry" ]
62+
debouncer <- liftEffect $ Ref.new (Map.singleton registryPath now)
63+
cacheRef <- liftEffect Cache.newCacheRef
64+
let
65+
env =
66+
{ repos:
67+
{ registry: { owner: "test", repo: "test" }
68+
, manifestIndex: { owner: "test", repo: "test" }
69+
, legacyPackageSets: { owner: "test", repo: "test" }
70+
}
71+
, workdir: tmp
72+
, pull: Git.ForceClean
73+
, write: ReadOnly
74+
, debouncer
75+
, cacheRef
76+
}
77+
78+
-- Step 1: readMetadata for one package.
79+
-- Before the fix, resetFromDisk seeded the AllMetadata cache with
80+
-- Map.singleton prelude metadata. After the fix, the cache is left alone.
81+
_ <- runRealRegistry env $ Registry.readMetadata (unsafePackageName "prelude")
82+
83+
-- Step 2: readAllMetadata under Git.NoChange.
84+
-- Before the fix, the singleton cache from step 1 was returned verbatim
85+
-- and the assertion below would see size 1. After the fix, the handler
86+
-- reads all three metadata files from disk.
87+
allMetadata <- runRealRegistry env $ Registry.readAllMetadata
88+
89+
Map.size allMetadata `Assert.shouldEqual` 3
90+
91+
where
92+
packages =
93+
[ { name: "prelude", version: "6.0.1", compilers: [ "0.15.15" ] }
94+
, { name: "effect", version: "4.0.0", compilers: [ "0.15.15" ] }
95+
, { name: "control", version: "6.0.0", compilers: [ "0.15.15" ] }
96+
]
97+
98+
-- | Run the REGISTRY effect - can't use the mock here because the regression
99+
-- | we are testing is in the caching code of the handle
100+
runRealRegistry
101+
:: forall a
102+
. RegistryEnv
103+
-> Run (REGISTRY + GITHUB + LOG + EXCEPT String + AFF + EFFECT + ()) a
104+
-> Aff a
105+
runRealRegistry env =
106+
Registry.interpret (Registry.handle env)
107+
>>> GitHub.interpret handleGitHubStub
108+
>>> Log.interpret (\(Log _ _ next) -> pure next)
109+
>>> Except.catch (\err -> Run.liftAff (Aff.throwError (Aff.error err)))
110+
>>> Run.runBaseAff'
111+
112+
-- | Stub GitHub handler — crashes if called. ReadMetadata and ReadAllMetadata
113+
-- | don't use the GITHUB effect, so this should never be reached.
114+
handleGitHubStub :: forall r a. GitHub a -> Run r a
115+
handleGitHubStub _ = unsafeCrashWith "GITHUB effect should not be called in this test"

0 commit comments

Comments
 (0)