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  }