From fa14481796188287737e676616a54f4e5c5406a2 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 8 Nov 2024 16:24:32 +1100 Subject: [PATCH] feat: detect when `go mod tidy` and build scaffolding is needed (#3360) closes #3129 Increases performance of go builds by 0.5s-0.8s in some cases. Changes: - Track if any thing has changed that would require us to - call `go mod tidy` (in either the main module directory, or in the build directory) - scaffold the build template (if we scaffold, we also need to run go mod tidy) - If a build fails for any reason, reset ongoing build state so next time we always rebuild everything. Trigger details: - If we detect changes in `go.mod`, `go.sum` or `types.ftl.go`, always rebuild fully - If the list of imported packages has changed, `go mod tidy` is required in the module's directory - If the MainModuleContext has changed in any way, then the build template needs to be scaffolded, and `go mod tidy` needs to be run in `.ftl/go/main` Performace: - Scaffolding is super fast - Running `go mod tidy` usually takes around 0.5-0.8s (but can take more if network requests are required) - The 2 `go mod tidy` executions are run in parallel, so if either is required then there is no real performance gain. If neither is required then we see the performance improvement. - The rest of the build time is split between extracting schema (0.5-1.5s) and compiling (0.5-1.5s) --- go-runtime/compile/build.go | 114 +++++++++++++++--- go-runtime/compile/dependencies.go | 15 ++- .../compile/main-work-template/go.work.tmpl | 3 + go-runtime/compile/stubs.go | 9 ++ go-runtime/goplugin/service.go | 65 +++++----- internal/watch/filehash.go | 16 ++- internal/watch/watch.go | 30 ++++- jvm-runtime/plugin/common/jvmcommon.go | 2 +- 8 files changed, 186 insertions(+), 68 deletions(-) diff --git a/go-runtime/compile/build.go b/go-runtime/compile/build.go index 5968a0b9dc..631ad1c54f 100644 --- a/go-runtime/compile/build.go +++ b/go-runtime/compile/build.go @@ -2,6 +2,7 @@ package compile import ( "context" + "errors" "fmt" "os" "path" @@ -31,12 +32,17 @@ import ( "github.com/TBD54566975/ftl/internal/projectconfig" "github.com/TBD54566975/ftl/internal/reflect" "github.com/TBD54566975/ftl/internal/schema" + islices "github.com/TBD54566975/ftl/internal/slices" "github.com/TBD54566975/ftl/internal/watch" ) +var ErrInvalidateDependencies = errors.New("dependencies need to be updated") +var ftlTypesFilename = "types.ftl.go" + type MainWorkContext struct { GoVersion string SharedModulesPaths []string + IncludeMainPackage bool } type mainModuleContext struct { @@ -78,7 +84,9 @@ func (c *mainModuleContext) generateMainImports() []string { for _, e := range c.MainCtx.ExternalTypes { imports.Add(e.importStatement()) } - return imports.ToSlice() + out := imports.ToSlice() + slices.Sort(out) + return out } func (c *mainModuleContext) generateTypesImports(mainModuleImport string) []string { @@ -112,6 +120,7 @@ func (c *mainModuleContext) generateTypesImports(mainModuleImport string) []stri } filteredImports = append(filteredImports, im) } + slices.Sort(filteredImports) return filteredImports } @@ -262,8 +271,55 @@ func buildDir(moduleDir string) string { return filepath.Join(moduleDir, buildDirName) } +// OngoingState maintains state between builds, allowing the Build function to skip steps if nothing has changed. +type OngoingState struct { + imports []string + moduleCtx mainModuleContext +} + +func (s *OngoingState) checkIfImportsChanged(imports []string) (changed bool) { + if slices.Equal(s.imports, imports) { + return false + } + s.imports = imports + return true +} + +func (s *OngoingState) checkIfMainModuleContextChanged(moduleCtx mainModuleContext) (changed bool) { + if stdreflect.DeepEqual(s.moduleCtx, moduleCtx) { + return false + } + s.moduleCtx = moduleCtx + return true +} + +// DetectedFileChanges should be called whenever file changes are detected outside of the Build() function. +// This allows the OngoingState to detect if files need to be reprocessed. +func (s *OngoingState) DetectedFileChanges(config moduleconfig.AbsModuleConfig, changes []watch.FileChange) { + paths := []string{ + filepath.Join(config.Dir, ftlTypesFilename), + filepath.Join(config.Dir, "go.mod"), + filepath.Join(config.Dir, "go.sum"), + } + for _, change := range changes { + if !slices.Contains(paths, change.Path) { + continue + } + // If files altered by Build() have been manually changed, reset state to make sure we correct them if needed. + s.reset() + return + } +} + +func (s *OngoingState) reset() { + s.imports = nil + s.moduleCtx = mainModuleContext{} +} + // Build the given module. -func Build(ctx context.Context, projectRootDir, stubsRoot string, config moduleconfig.AbsModuleConfig, sch *schema.Schema, filesTransaction watch.ModifyFilesTransaction, buildEnv []string, devMode bool) (moduleSch optional.Option[*schema.Module], buildErrors []builderrors.Error, err error) { +func Build(ctx context.Context, projectRootDir, stubsRoot string, config moduleconfig.AbsModuleConfig, + sch *schema.Schema, deps, buildEnv []string, filesTransaction watch.ModifyFilesTransaction, ongoingState *OngoingState, + devMode bool) (moduleSch optional.Option[*schema.Module], buildErrors []builderrors.Error, err error) { if err := filesTransaction.Begin(); err != nil { return moduleSch, nil, fmt.Errorf("could not start a file transaction: %w", err) } @@ -271,8 +327,24 @@ func Build(ctx context.Context, projectRootDir, stubsRoot string, config modulec if terr := filesTransaction.End(); terr != nil { err = fmt.Errorf("failed to end file transaction: %w", terr) } + if err != nil { + // If we failed, reset the state to ensure we don't skip steps on the next build. + // Example: If `go mod tidy` fails due to a network failure, we need to try again next time, even if nothing else has changed. + ongoingState.reset() + } }() + // Check dependencies + newDeps, imports, err := extractDependenciesAndImports(config) + if err != nil { + return moduleSch, nil, fmt.Errorf("could not extract dependencies: %w", err) + } + importsChanged := ongoingState.checkIfImportsChanged(imports) + if !slices.Equal(islices.Sort(newDeps), islices.Sort(deps)) { + // dependencies have changed + return moduleSch, nil, ErrInvalidateDependencies + } + replacements, goModVersion, err := updateGoModule(filepath.Join(config.Dir, "go.mod")) if err != nil { return moduleSch, nil, err @@ -283,11 +355,6 @@ func Build(ctx context.Context, projectRootDir, stubsRoot string, config modulec return moduleSch, nil, fmt.Errorf("go version %q is not recent enough for this module, needs minimum version %q", goVersion, goModVersion) } - ftlVersion := "" - if ftl.IsRelease(ftl.Version) { - ftlVersion = ftl.Version - } - projectName := "" if pcpath, ok := projectconfig.DefaultConfigPath().Get(); ok { pc, err := projectconfig.Load(ctx, pcpath) @@ -317,6 +384,7 @@ func Build(ctx context.Context, projectRootDir, stubsRoot string, config modulec if err := internal.ScaffoldZip(mainWorkTemplateFiles(), config.Dir, MainWorkContext{ GoVersion: goModVersion, SharedModulesPaths: sharedModulesPaths, + IncludeMainPackage: mainPackageExists(config), }, scaffolder.Exclude("^go.mod$"), scaffolder.Functions(funcs)); err != nil { return moduleSch, nil, fmt.Errorf("failed to scaffold zip: %w", err) } @@ -334,25 +402,33 @@ func Build(ctx context.Context, projectRootDir, stubsRoot string, config modulec } logger.Debugf("Generating main module") - mctx, err := buildMainModuleContext(sch, result, goModVersion, ftlVersion, projectName, sharedModulesPaths, + mctx, err := buildMainModuleContext(sch, result, goModVersion, projectName, sharedModulesPaths, replacements) if err != nil { return moduleSch, nil, err } - if err := internal.ScaffoldZip(buildTemplateFiles(), config.Dir, mctx, scaffolder.Exclude("^go.mod$"), - scaffolder.Functions(funcs)); err != nil { - return moduleSch, nil, fmt.Errorf("failed to scaffold build template: %w", err) - } + mainModuleCtxChanged := ongoingState.checkIfMainModuleContextChanged(mctx) + if mainModuleCtxChanged { + if err := internal.ScaffoldZip(buildTemplateFiles(), config.Dir, mctx, scaffolder.Exclude("^go.mod$"), + scaffolder.Functions(funcs)); err != nil { + return moduleSch, nil, fmt.Errorf("failed to scaffold build template: %w", err) + } + if err := filesTransaction.ModifiedFiles(filepath.Join(config.Dir, ftlTypesFilename)); err != nil { + return moduleSch, nil, fmt.Errorf("failed to mark %s as modified: %w", ftlTypesFilename, err) + } + } logger.Debugf("Tidying go.mod files") wg, wgctx := errgroup.WithContext(ctx) - ftlTypesFilename := "types.ftl.go" wg.Go(func() error { + if !importsChanged { + log.FromContext(ctx).Debugf("skipped go mod tidy (module dir)\n") + return nil + } if err := exec.Command(wgctx, log.Debug, config.Dir, "go", "mod", "tidy").RunStderrError(wgctx); err != nil { return fmt.Errorf("%s: failed to tidy go.mod: %w", config.Dir, err) } - if err := exec.Command(wgctx, log.Debug, config.Dir, "go", "fmt", ftlTypesFilename).RunStderrError(wgctx); err != nil { return fmt.Errorf("%s: failed to format module dir: %w", config.Dir, err) } @@ -360,6 +436,10 @@ func Build(ctx context.Context, projectRootDir, stubsRoot string, config modulec }) mainDir := filepath.Join(buildDir, "go", "main") wg.Go(func() error { + if !mainModuleCtxChanged { + log.FromContext(ctx).Debugf("skipped go mod tidy (build dir)\n") + return nil + } if err := exec.Command(wgctx, log.Debug, mainDir, "go", "mod", "tidy").RunStderrError(wgctx); err != nil { return fmt.Errorf("%s: failed to tidy go.mod: %w", mainDir, err) } @@ -405,8 +485,12 @@ type mainModuleContextBuilder struct { imports map[string]string } -func buildMainModuleContext(sch *schema.Schema, result extract.Result, goModVersion, ftlVersion, projectName string, +func buildMainModuleContext(sch *schema.Schema, result extract.Result, goModVersion, projectName string, sharedModulesPaths []string, replacements []*modfile.Replace) (mainModuleContext, error) { + ftlVersion := "" + if ftl.IsRelease(ftl.Version) { + ftlVersion = ftl.Version + } combinedSch := &schema.Schema{ Modules: append(sch.Modules, result.Module), } diff --git a/go-runtime/compile/dependencies.go b/go-runtime/compile/dependencies.go index 6a11c10295..6232c74ede 100644 --- a/go-runtime/compile/dependencies.go +++ b/go-runtime/compile/dependencies.go @@ -16,9 +16,15 @@ import ( ) func ExtractDependencies(config moduleconfig.AbsModuleConfig) ([]string, error) { + deps, _, err := extractDependenciesAndImports(config) + return deps, err +} + +func extractDependenciesAndImports(config moduleconfig.AbsModuleConfig) (deps []string, imports []string, err error) { + importsMap := map[string]bool{} dependencies := map[string]bool{} fset := token.NewFileSet() - err := watch.WalkDir(config.Dir, func(path string, d fs.DirEntry) error { + err = watch.WalkDir(config.Dir, func(path string, d fs.DirEntry) error { if !d.IsDir() { return nil } @@ -36,6 +42,7 @@ func ExtractDependencies(config moduleconfig.AbsModuleConfig) ([]string, error) if err != nil { continue } + importsMap[path] = true if !strings.HasPrefix(path, "ftl/") { continue } @@ -50,9 +57,11 @@ func ExtractDependencies(config moduleconfig.AbsModuleConfig) ([]string, error) return nil }) if err != nil { - return nil, fmt.Errorf("%s: failed to extract dependencies from Go module: %w", config.Module, err) + return nil, nil, fmt.Errorf("%s: failed to extract dependencies from Go module: %w", config.Module, err) } modules := maps.Keys(dependencies) sort.Strings(modules) - return modules, nil + imports = maps.Keys(importsMap) + sort.Strings(imports) + return modules, imports, nil } diff --git a/go-runtime/compile/main-work-template/go.work.tmpl b/go-runtime/compile/main-work-template/go.work.tmpl index 1dbed607a3..7e417ed74d 100644 --- a/go-runtime/compile/main-work-template/go.work.tmpl +++ b/go-runtime/compile/main-work-template/go.work.tmpl @@ -5,4 +5,7 @@ use ( {{- range .SharedModulesPaths }} {{ . }} {{- end }} +{{ if .IncludeMainPackage }} + .ftl/go/main +{{ end }} ) diff --git a/go-runtime/compile/stubs.go b/go-runtime/compile/stubs.go index 618b78e8eb..794e9885a3 100644 --- a/go-runtime/compile/stubs.go +++ b/go-runtime/compile/stubs.go @@ -2,7 +2,9 @@ package compile import ( "context" + "errors" "fmt" + "os" "path/filepath" "runtime" @@ -102,8 +104,15 @@ func SyncGeneratedStubReferences(ctx context.Context, config moduleconfig.AbsMod if err := internal.ScaffoldZip(mainWorkTemplateFiles(), config.Dir, MainWorkContext{ GoVersion: goModVersion, SharedModulesPaths: sharedModulePaths, + IncludeMainPackage: mainPackageExists(config), }, scaffolder.Exclude("^go.mod$"), scaffolder.Functions(funcs)); err != nil { return fmt.Errorf("failed to scaffold zip: %w", err) } return nil } + +func mainPackageExists(config moduleconfig.AbsModuleConfig) bool { + // check if main package exists, otherwise do not include it + _, err := os.Stat(filepath.Join(buildDir(config.Dir), "go", "main", "go.mod")) + return !errors.Is(err, os.ErrNotExist) +} diff --git a/go-runtime/goplugin/service.go b/go-runtime/goplugin/service.go index 0bcf1ef7c9..4a8c97c02f 100644 --- a/go-runtime/goplugin/service.go +++ b/go-runtime/goplugin/service.go @@ -2,10 +2,10 @@ package goplugin import ( "context" + "errors" "fmt" "path/filepath" "runtime" - "slices" "strings" "time" @@ -28,7 +28,6 @@ import ( "github.com/TBD54566975/ftl/internal/log" "github.com/TBD54566975/ftl/internal/moduleconfig" "github.com/TBD54566975/ftl/internal/schema" - islices "github.com/TBD54566975/ftl/internal/slices" "github.com/TBD54566975/ftl/internal/watch" ) @@ -43,7 +42,9 @@ type buildContextUpdatedEvent struct { func (buildContextUpdatedEvent) updateEvent() {} -type filesUpdatedEvent struct{} +type filesUpdatedEvent struct { + changes []watch.FileChange +} func (filesUpdatedEvent) updateEvent() {} @@ -237,6 +238,7 @@ func (s *Service) Build(ctx context.Context, req *connect.Request[langpb.BuildRe } watcher := watch.NewWatcher(watchPatterns...) + if req.Msg.RebuildAutomatically { s.acceptsContextUpdates.Store(true) defer s.acceptsContextUpdates.Store(false) @@ -247,7 +249,8 @@ func (s *Service) Build(ctx context.Context, req *connect.Request[langpb.BuildRe } // Initial build - if err := buildAndSend(ctx, stream, req.Msg.ProjectRoot, req.Msg.StubsRoot, buildCtx, false, watcher.GetTransaction(buildCtx.Config.Dir)); err != nil { + ongoingState := &compile.OngoingState{} + if err := buildAndSend(ctx, stream, req.Msg.ProjectRoot, req.Msg.StubsRoot, buildCtx, false, watcher.GetTransaction(buildCtx.Config.Dir), ongoingState); err != nil { return err } if !req.Msg.RebuildAutomatically { @@ -259,7 +262,7 @@ func (s *Service) Build(ctx context.Context, req *connect.Request[langpb.BuildRe select { case e := <-events: var isAutomaticRebuild bool - buildCtx, isAutomaticRebuild = buildContextFromPendingEvents(ctx, buildCtx, events, e) + buildCtx, isAutomaticRebuild = buildContextFromPendingEvents(ctx, buildCtx, events, e, ongoingState) if isAutomaticRebuild { err = stream.Send(&langpb.BuildEvent{ Event: &langpb.BuildEvent_AutoRebuildStarted{ @@ -272,7 +275,7 @@ func (s *Service) Build(ctx context.Context, req *connect.Request[langpb.BuildRe return fmt.Errorf("could not send auto rebuild started event: %w", err) } } - if err = buildAndSend(ctx, stream, req.Msg.ProjectRoot, req.Msg.StubsRoot, buildCtx, isAutomaticRebuild, watcher.GetTransaction(buildCtx.Config.Dir)); err != nil { + if err = buildAndSend(ctx, stream, req.Msg.ProjectRoot, req.Msg.StubsRoot, buildCtx, isAutomaticRebuild, watcher.GetTransaction(buildCtx.Config.Dir), ongoingState); err != nil { return err } case <-ctx.Done(): @@ -294,11 +297,9 @@ func (s *Service) BuildContextUpdated(ctx context.Context, req *connect.Request[ if err != nil { return nil, err } - s.updatesTopic.Publish(buildContextUpdatedEvent{ buildCtx: buildCtx, }) - return connect.NewResponse(&langpb.BuildContextUpdatedResponse{}), nil } @@ -328,9 +329,9 @@ func watchFiles(ctx context.Context, watcher *watch.Watcher, buildCtx buildConte for { select { case e := <-watchEvents: - if _, ok := e.(watch.WatchEventModuleChanged); ok { - log.FromContext(ctx).Infof("Found file changes: %s", buildCtx.Config.Dir) - events <- filesUpdatedEvent{} + if change, ok := e.(watch.WatchEventModuleChanged); ok { + log.FromContext(ctx).Infof("Found file changes: %s", change) + events <- filesUpdatedEvent{changes: change.Changes} } case <-ctx.Done(): @@ -342,7 +343,7 @@ func watchFiles(ctx context.Context, watcher *watch.Watcher, buildCtx buildConte } // buildContextFromPendingEvents processes all pending events to determine the latest context and whether the build is automatic. -func buildContextFromPendingEvents(ctx context.Context, buildCtx buildContext, events chan updateEvent, firstEvent updateEvent) (newBuildCtx buildContext, isAutomaticRebuild bool) { +func buildContextFromPendingEvents(ctx context.Context, buildCtx buildContext, events chan updateEvent, firstEvent updateEvent, ongoingState *compile.OngoingState) (newBuildCtx buildContext, isAutomaticRebuild bool) { allEvents := []updateEvent{firstEvent} // find any other events in the queue for { @@ -360,14 +361,15 @@ func buildContextFromPendingEvents(ctx context.Context, buildCtx buildContext, e buildCtx = e.buildCtx hasExplicitBuilt = true case filesUpdatedEvent: + ongoingState.DetectedFileChanges(buildCtx.Config, e.changes) } - } switch e := firstEvent.(type) { case buildContextUpdatedEvent: buildCtx = e.buildCtx hasExplicitBuilt = true case filesUpdatedEvent: + ongoingState.DetectedFileChanges(buildCtx.Config, e.changes) } return buildCtx, !hasExplicitBuilt } @@ -378,8 +380,9 @@ func buildContextFromPendingEvents(ctx context.Context, buildCtx buildContext, e // // Build errors are sent over the stream as a BuildFailure event. // This function only returns an error if events could not be send over the stream. -func buildAndSend(ctx context.Context, stream *connect.ServerStream[langpb.BuildEvent], projectRoot, stubsRoot string, buildCtx buildContext, isAutomaticRebuild bool, transaction watch.ModifyFilesTransaction) error { - buildEvent, err := build(ctx, projectRoot, stubsRoot, buildCtx, isAutomaticRebuild, transaction) +func buildAndSend(ctx context.Context, stream *connect.ServerStream[langpb.BuildEvent], projectRoot, stubsRoot string, buildCtx buildContext, + isAutomaticRebuild bool, transaction watch.ModifyFilesTransaction, ongoingState *compile.OngoingState) error { + buildEvent, err := build(ctx, projectRoot, stubsRoot, buildCtx, isAutomaticRebuild, transaction, ongoingState) if err != nil { buildEvent = buildFailure(buildCtx, isAutomaticRebuild, builderrors.Error{ Type: builderrors.FTL, @@ -393,33 +396,27 @@ func buildAndSend(ctx context.Context, stream *connect.ServerStream[langpb.Build return nil } -func build(ctx context.Context, projectRoot, stubsRoot string, buildCtx buildContext, isAutomaticRebuild bool, transaction watch.ModifyFilesTransaction) (*langpb.BuildEvent, error) { +func build(ctx context.Context, projectRoot, stubsRoot string, buildCtx buildContext, isAutomaticRebuild bool, transaction watch.ModifyFilesTransaction, + ongoingState *compile.OngoingState) (*langpb.BuildEvent, error) { release, err := flock.Acquire(ctx, buildCtx.Config.BuildLock, BuildLockTimeout) if err != nil { return nil, fmt.Errorf("could not acquire build lock: %w", err) } defer release() //nolint:errcheck - deps, err := compile.ExtractDependencies(buildCtx.Config) + m, buildErrs, err := compile.Build(ctx, projectRoot, stubsRoot, buildCtx.Config, buildCtx.Schema, buildCtx.Dependencies, buildCtx.BuildEnv, transaction, ongoingState, false) if err != nil { - return nil, fmt.Errorf("could not extract dependencies: %w", err) - } - - if !slices.Equal(islices.Sort(deps), islices.Sort(buildCtx.Dependencies)) { - // dependencies have changed - return &langpb.BuildEvent{ - Event: &langpb.BuildEvent_BuildFailure{ - BuildFailure: &langpb.BuildFailure{ - ContextId: buildCtx.ID, - IsAutomaticRebuild: isAutomaticRebuild, - InvalidateDependencies: true, + if errors.Is(err, compile.ErrInvalidateDependencies) { + return &langpb.BuildEvent{ + Event: &langpb.BuildEvent_BuildFailure{ + BuildFailure: &langpb.BuildFailure{ + ContextId: buildCtx.ID, + IsAutomaticRebuild: isAutomaticRebuild, + InvalidateDependencies: true, + }, }, - }, - }, nil - } - - m, buildErrs, err := compile.Build(ctx, projectRoot, stubsRoot, buildCtx.Config, buildCtx.Schema, transaction, buildCtx.BuildEnv, false) - if err != nil { + }, nil + } return buildFailure(buildCtx, isAutomaticRebuild, builderrors.Error{ Type: builderrors.COMPILER, Level: builderrors.ERROR, diff --git a/internal/watch/filehash.go b/internal/watch/filehash.go index b61cb57692..dd52b4ca82 100644 --- a/internal/watch/filehash.go +++ b/internal/watch/filehash.go @@ -38,28 +38,26 @@ type FileHashes map[string][]byte // CompareFileHashes compares the hashes of the files in the oldFiles and newFiles maps. // -// Returns true if the hashes are equal, false otherwise. -// -// If false, the returned string will be a file that caused the difference and the -// returned FileChangeType will be the type of change that occurred. -func CompareFileHashes(oldFiles, newFiles FileHashes) (FileChangeType, string, bool) { +// Returns all file changes +func CompareFileHashes(oldFiles, newFiles FileHashes) []FileChange { + changes := []FileChange{} for key, hash1 := range oldFiles { hash2, exists := newFiles[key] if !exists { - return FileRemoved, key, false + changes = append(changes, FileChange{Change: FileRemoved, Path: key}) } if !bytes.Equal(hash1, hash2) { - return FileChanged, key, false + changes = append(changes, FileChange{Change: FileChanged, Path: key}) } } for key := range newFiles { if _, exists := oldFiles[key]; !exists { - return FileAdded, key, false + changes = append(changes, FileChange{Change: FileAdded, Path: key}) } } - return ' ', "", true + return changes } // ComputeFileHashes computes the SHA256 hash of all (non-git-ignored) files in diff --git a/internal/watch/watch.go b/internal/watch/watch.go index f1d02b98db..8ee2835bfe 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -3,6 +3,8 @@ package watch import ( "context" "fmt" + "path/filepath" + "strings" "sync" "time" @@ -11,6 +13,7 @@ import ( "github.com/TBD54566975/ftl/internal/log" "github.com/TBD54566975/ftl/internal/maps" "github.com/TBD54566975/ftl/internal/moduleconfig" + "github.com/TBD54566975/ftl/internal/slices" ) // A WatchEvent is an event that occurs when a module is added, removed, or @@ -30,10 +33,24 @@ type WatchEventModuleRemoved struct { func (WatchEventModuleRemoved) watchEvent() {} type WatchEventModuleChanged struct { - Config moduleconfig.UnvalidatedModuleConfig + Config moduleconfig.UnvalidatedModuleConfig + Changes []FileChange + Time time.Time +} + +func (c WatchEventModuleChanged) String() string { + return strings.Join(slices.Map(c.Changes, func(change FileChange) string { + p, err := filepath.Rel(c.Config.Dir, change.Path) + if err != nil { + p = change.Path + } + return fmt.Sprintf("%s%s", change.Change, p) + }), ", ") +} + +type FileChange struct { Change FileChangeType Path string - Time time.Time } func (WatchEventModuleChanged) watchEvent() {} @@ -147,12 +164,13 @@ func (w *Watcher) Watch(ctx context.Context, period time.Duration, moduleDirs [] } if haveExistingModule { - changeType, path, equal := CompareFileHashes(existingModule.Hashes, hashes) - if equal { + changes := CompareFileHashes(existingModule.Hashes, hashes) + if len(changes) == 0 { continue } - logger.Debugf("changed %q: %c%s", config.Module, changeType, path) - topic.Publish(WatchEventModuleChanged{Config: existingModule.Config, Change: changeType, Path: path, Time: time.Now()}) + event := WatchEventModuleChanged{Config: existingModule.Config, Changes: changes, Time: time.Now()} + logger.Debugf("changed %q: %s", config.Module, event) + topic.Publish(event) w.existingModules[config.Dir] = moduleHashes{Hashes: hashes, Config: existingModule.Config} continue } diff --git a/jvm-runtime/plugin/common/jvmcommon.go b/jvm-runtime/plugin/common/jvmcommon.go index 963dc05183..4259b2e038 100644 --- a/jvm-runtime/plugin/common/jvmcommon.go +++ b/jvm-runtime/plugin/common/jvmcommon.go @@ -374,7 +374,7 @@ func watchFiles(ctx context.Context, watcher *watch.Watcher, buildCtx buildConte select { case e := <-watchEvents: if change, ok := e.(watch.WatchEventModuleChanged); ok { - log.FromContext(ctx).Infof("Found file changes: %s", change.Path) + log.FromContext(ctx).Infof("Found file changes: %s", change) events <- filesUpdatedEvent{} }