github.com/brandur/modulir@v0.0.0-20240305213423-94ee82929cbd/context.go (about) 1 package modulir 2 3 import ( 4 "os" 5 "path/filepath" 6 "sync" 7 "time" 8 9 "github.com/fsnotify/fsnotify" 10 "golang.org/x/xerrors" 11 ) 12 13 ////////////////////////////////////////////////////////////////////////////// 14 // 15 // 16 // 17 // Public 18 // 19 // 20 // 21 ////////////////////////////////////////////////////////////////////////////// 22 23 // Args are the set of arguments accepted by NewContext. 24 type Args struct { 25 Concurrency int 26 Log LoggerInterface 27 LogColor bool 28 Pool *Pool 29 Port int 30 SourceDir string 31 TargetDir string 32 Watcher *fsnotify.Watcher 33 Websocket bool 34 } 35 36 // Context contains useful state that can be used by a user-provided build 37 // function. 38 type Context struct { 39 // Concurrency is the number of concurrent workers to run during the build 40 // step. 41 Concurrency int 42 43 // FirstRun indicates whether this is the first run of the build loop. 44 FirstRun bool 45 46 // Forced causes the Changed function to always return true regardless of 47 // the path it's invoked on, thereby prompting all jobs that use it to 48 // execute. 49 // 50 // Make sure to unset it after your build run is finished. 51 Forced bool 52 53 // Jobs is a channel over which jobs to be done are transmitted. 54 Jobs chan *Job 55 56 // Log is a logger that can be used to print information. 57 Log LoggerInterface 58 59 // LogColor specifies whether messages sent to Log should be color. You may 60 // want to set to true if you know output is going to a terminal. 61 LogColor bool 62 63 // Pool is the job pool used to build the static site. 64 Pool *Pool 65 66 // Port specifies the port on which to serve content from TargetDir over 67 // HTTP. 68 Port int 69 70 // QuickPaths are a set of paths for which Changed will return true when 71 // the context is in "quick rebuild mode". During this time all the normal 72 // file system checks that Changed makes will be bypassed to enable a 73 // faster build loop. 74 // 75 // Make sure that all paths added here are normalized with filepath.Clean. 76 // 77 // Make sure to unset it after your build run is finished. 78 QuickPaths map[string]struct{} 79 80 // SourceDir is the directory containing source files. 81 SourceDir string 82 83 // Stats tracks various statistics about the build process. 84 // 85 // Statistics are reset between build loops, but are cumulative between 86 // build phases within a loop (i.e. calls to Wait). 87 Stats *Stats 88 89 // TargetDir is the directory where the site will be built to. 90 TargetDir string 91 92 // Watcher is a file system watcher that picks up changes to source files 93 // and restarts the build loop. 94 Watcher *fsnotify.Watcher 95 96 // Websocket indicates that Modulir should be started in development 97 // mode with a websocket that provides features like live reload. 98 // 99 // Defaults to false. 100 Websocket bool 101 102 // Helper for producing rich colors and styles to the log. 103 colorizer *colorizer 104 105 // fileModTimeCache remembers the last modified times of files. 106 fileModTimeCache *fileModTimeCache 107 108 // watchedPaths are the set of paths that we're currently watching. This 109 // information is tracked internally by fsnotify as well, but we track it here 110 // as well to help with debugging (for "too many open files" problems and the 111 // like). 112 watchedPaths map[string]struct{} 113 114 // watchedPathsMu synchronizes concurrent access to watchedPaths. 115 watchedPathsMu sync.RWMutex 116 } 117 118 // NewContext initializes and returns a new Context. 119 func NewContext(args *Args) *Context { 120 c := &Context{ 121 Concurrency: args.Concurrency, 122 FirstRun: true, 123 Log: args.Log, 124 LogColor: args.LogColor, 125 Pool: args.Pool, 126 Port: args.Port, 127 SourceDir: args.SourceDir, 128 Stats: &Stats{}, 129 TargetDir: args.TargetDir, 130 Watcher: args.Watcher, 131 Websocket: args.Websocket, 132 133 colorizer: &colorizer{LogColor: args.LogColor}, 134 fileModTimeCache: newFileModTimeCache(args.Log), 135 watchedPaths: make(map[string]struct{}), 136 } 137 138 if args.Pool != nil { 139 args.Pool.colorizer = c.colorizer 140 c.Jobs = args.Pool.Jobs 141 } 142 143 return c 144 } 145 146 // AddJob is a shortcut for adding a new job to the Jobs channel. 147 func (c *Context) AddJob(name string, f func() (bool, error)) { 148 c.Jobs <- NewJob(name, f) 149 } 150 151 // AllowError is a helper that's useful for when an error coming back from a 152 // job should be logged, but shouldn't fail the build. 153 func (c *Context) AllowError(executed bool, err error) bool { 154 if err != nil { 155 c.Log.Errorf("%s %v", c.colorizer.Bold(c.colorizer.Yellow("Error allowed:")), err) 156 } 157 return executed 158 } 159 160 // Changed returns whether the target path's modified time has changed since 161 // the last time it was checked. It also saves the last modified time for 162 // future checks. 163 // 164 // This function is very hot in that it gets checked many times, and probably 165 // many times for every single job in a build loop. It needs to be optimized 166 // fairly carefully for both speed and lack of contention when running 167 // concurrently with other jobs. 168 func (c *Context) Changed(path string) bool { 169 // Always return immediately if the context has been forced. 170 if c.Forced { 171 return true 172 } 173 174 // Make sure we're always operating against a normalized path. 175 // 176 // Note that fsnotify sends us cleaned paths which are what gets added to 177 // QuickPaths below, so cleaning here ensures that we're always comparing 178 // against the right thing. 179 path = filepath.Clean(path) 180 181 // Short circuit quickly if the context is in "quick rebuild mode". 182 if c.QuickPaths != nil { 183 _, ok := c.QuickPaths[path] 184 return ok 185 } 186 187 fileInfo, err := os.Stat(path) 188 if err != nil { 189 if !os.IsNotExist(err) { 190 c.Log.Errorf("Path passed to Changed doesn't exist: %s", path) 191 } 192 return true 193 } 194 195 changed, ok := c.fileModTimeCache.isFileUpdated(fileInfo, path) 196 197 // If we got ok back, then we know the file was in the cache and also 198 // therefore would've been already watched. Return as early as possible. 199 if ok { 200 return changed 201 } 202 203 if c.Watcher != nil { 204 err := c.addWatched(fileInfo, path) 205 if err != nil { 206 // Unfortunately the number shown here is misleading because 207 // fsnotify may have recursively added a bunch of file watches 208 // under a directory. 209 c.Log.Errorf("Error watching source: %v (num watches is %v)", 210 err, len(c.watchedPaths)) 211 } 212 } 213 214 return true 215 } 216 217 // ChangedAny is the same as Changed except it returns true if any of the given 218 // paths have changed. 219 func (c *Context) ChangedAny(paths ...string) bool { 220 // We have to run through every element in paths even if we detect changed 221 // early so that each is correctly added to the file mod time cache and 222 // watched. 223 changed := false 224 225 for _, path := range paths { 226 // Make sure that c.Changed appears first or there seems to be a danger 227 // of Go compiling it out. 228 changed = c.Changed(path) || changed 229 } 230 231 return changed 232 } 233 234 // ResetBuild signals to the Context to do the bookkeeping it needs to do for 235 // the next build round. 236 func (c *Context) ResetBuild() { 237 c.Log.Debugf("Context ResetBuild()") 238 c.Stats.Reset() 239 c.fileModTimeCache.promote() 240 } 241 242 // StartRound starts a new round for the context, also starting it on its 243 // attached job pool. 244 func (c *Context) StartRound() { 245 c.Log.Debugf("Context StartRound()") 246 247 // Value before we increment to keep round number zero-indexed 248 roundNum := c.Stats.NumRounds 249 250 c.Stats.NumRounds++ 251 252 // Then start the pool again, which also has the side effect of 253 // reinitializing anything that needs to be reinitialized. 254 c.Pool.StartRound(roundNum) 255 256 // This channel is reinitialized, so make sure to pull in the new one. 257 c.Jobs = c.Pool.Jobs 258 } 259 260 // Wait waits on the job pool to execute its current round of jobs. 261 // 262 // The worker pool is then primed for a new round so that more jobs can be 263 // enqueued. 264 // 265 // Returns nil if the round of jobs executed successfully, and a set of errors 266 // that occurred otherwise. 267 func (c *Context) Wait() []error { 268 c.Log.Debugf("Context Wait(); jobs queued: %v", len(c.Pool.Jobs)) 269 270 c.Stats.LoopDuration += time.Since(c.Stats.lastLoopStart) 271 272 defer func() { 273 // Reset the last loop start. 274 c.Stats.lastLoopStart = time.Now() 275 }() 276 277 // Wait for work to finish. 278 c.Pool.Wait() 279 280 // Note use of append even though we always expect the current set to be 281 // empty so that the slice is duplicated and not affected by its source 282 // being reset by `StartRound` below. 283 c.Stats.JobsErrored = append(c.Stats.JobsErrored, c.Pool.JobsErrored...) 284 285 c.Stats.JobsExecuted = append(c.Stats.JobsExecuted, c.Pool.JobsExecuted...) 286 c.Stats.NumJobs += len(c.Pool.JobsAll) 287 288 c.StartRound() 289 290 if c.Stats.JobsErrored == nil { 291 return nil 292 } 293 294 // Unfortunately required to coerce into `[]error` despite Job implementing 295 // the error interface. 296 errors := make([]error, len(c.Stats.JobsErrored)) 297 for i, job := range c.Stats.JobsErrored { 298 errors[i] = job 299 } 300 301 return errors 302 } 303 304 func (c *Context) addWatched(fileInfo os.FileInfo, absolutePath string) error { 305 // Watch the parent directory unless the file is a directory itself. This 306 // will hopefully mean fewer individual entries in the notifier. 307 if !fileInfo.IsDir() { 308 absolutePath = filepath.Dir(absolutePath) 309 } 310 311 absolutePath = filepath.Clean(absolutePath) 312 313 c.watchedPathsMu.RLock() 314 _, ok := c.watchedPaths[absolutePath] 315 c.watchedPathsMu.RUnlock() 316 if ok { 317 return nil 318 } 319 320 err := c.Watcher.Add(absolutePath) 321 if err != nil { 322 return xerrors.Errorf("error watching path '%s': %w", absolutePath, err) 323 } 324 325 c.watchedPathsMu.Lock() 326 c.watchedPaths[absolutePath] = struct{}{} 327 c.watchedPathsMu.Unlock() 328 329 return nil 330 } 331 332 // Stats tracks various statistics about the build process. 333 type Stats struct { 334 // JobsErrored is a slice of jobs that errored on the last run. 335 // 336 // Differs from JobsExecuted somewhat in that only one run of errors are 337 // tracked because the build loop fails through after a single unsuccessful 338 // run. 339 JobsErrored []*Job 340 341 // JobsExecuted is a slice of jobs that were executed across all runs. 342 JobsExecuted []*Job 343 344 // LoopDuration is the total amount of time spent in the user's build loop 345 // enqueuing jobs. Jobs may be running in the background during this time, 346 // but all the time spent waiting for jobs to finish is excluded. 347 LoopDuration time.Duration 348 349 // NumJobs is the total number of jobs generated for the build loop. 350 NumJobs int 351 352 // NumRounds is the number of "rounds" in the build which are used in the 353 // case of multi-step builds where jobs from one round may depend on the 354 // result of jobs from other rounds. 355 NumRounds int 356 357 // Start is the start time of the build loop. 358 Start time.Time 359 360 // lastLoopStart is when the last user build loop started (i.e. this is set 361 // to the current timestamp whenever a call to context.Wait finishes). 362 lastLoopStart time.Time 363 } 364 365 // Reset resets statistics. 366 func (s *Stats) Reset() { 367 s.JobsErrored = nil 368 s.JobsExecuted = nil 369 s.LoopDuration = time.Duration(0) 370 s.NumJobs = 0 371 s.NumRounds = 0 372 s.Start = time.Now() 373 s.lastLoopStart = time.Now() 374 } 375 376 ////////////////////////////////////////////////////////////////////////////// 377 // 378 // 379 // 380 // Private 381 // 382 // 383 // 384 ////////////////////////////////////////////////////////////////////////////// 385 386 // FileModTimeCache tracks the last modified time of files seen so a 387 // determination can be made as to whether they need to be recompiled. 388 type fileModTimeCache struct { 389 log LoggerInterface 390 mu sync.Mutex 391 pathToModTimeMap map[string]time.Time 392 pathToModTimeMapNew map[string]time.Time 393 } 394 395 // newFileModTimeCache returns a new fileModTimeCache. 396 func newFileModTimeCache(log LoggerInterface) *fileModTimeCache { 397 return &fileModTimeCache{ 398 log: log, 399 pathToModTimeMap: make(map[string]time.Time), 400 pathToModTimeMapNew: make(map[string]time.Time), 401 } 402 } 403 404 // changed returns whether the target path's modified time has changed since 405 // the last time it was checked. It also saves the last modified time for 406 // future checks. The second return value is whether or not the record was 407 // already in the cache. 408 func (c *fileModTimeCache) isFileUpdated(fileInfo os.FileInfo, absolutePath string) (bool, bool) { 409 modTime := fileInfo.ModTime() 410 411 lastModTime, ok := c.pathToModTimeMap[absolutePath] 412 413 if ok { 414 changed := lastModTime.Before(modTime) 415 if !changed { 416 return false, ok 417 } 418 } 419 420 // Store to the new map for eventual promotion. 421 c.mu.Lock() 422 c.pathToModTimeMapNew[absolutePath] = modTime 423 c.mu.Unlock() 424 425 return true, ok 426 } 427 428 // promote takes all the new modification times collected during this round 429 // (i.e. a build phase) and promotes them into the main map so that they're 430 // available for the next one. 431 func (c *fileModTimeCache) promote() { 432 c.mu.Lock() 433 defer c.mu.Unlock() 434 435 // Promote all new values to the current map. 436 for path, modTime := range c.pathToModTimeMapNew { 437 c.pathToModTimeMap[path] = modTime 438 } 439 440 // Clear the new map for the next round. 441 c.pathToModTimeMapNew = make(map[string]time.Time) 442 }